Scheduled reports and the weekly digest #160

Merged
jcoffey-dev merged 3 commits from feature/scheduled-reports into main 2026-10-06 22:41:05 +00:00
33 changed files with 3932 additions and 6 deletions

No files matched your search

Generated
+2
View File
@@ -3959,6 +3959,8 @@ dependencies = [
"ahash",
"aho-corasick",
"base64 0.23.1",
"chrono",
"chrono-tz",
"flate2",
"jmap_proto",
"mail-builder 1.0.0",
+6
View File
@@ -317,6 +317,12 @@ impl Default for DefaultPermissions {
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
default.superuser.push(permission);
}
// inbuxa: scheduled-reports spec, RP-22: a tenant administrator
// makes reports for their own tenant, which the server limits
Permission::SysScheduledReportGet | Permission::SysScheduledReportUpdate => {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: DLP and mail flow rules, and held mail, are the
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3)
@@ -32,8 +32,8 @@ use types::id::Id;
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec), accepting security
/// to-do items (security to-do list spec), and the deliverability check
/// (deliverability spec).
/// to-do items (security to-do list spec), the deliverability check
/// (deliverability spec), and scheduled reports (scheduled-reports spec).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
@@ -60,6 +60,8 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysDeliverabilityGet,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
Permission::SysScheduledReportGet,
Permission::SysScheduledReportUpdate,
];
/// Granted to the server-level Compliance Officer role once it exists:
@@ -77,8 +79,8 @@ const OFFICER_GRANTS: &[Permission] = &[
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), locking and delegating its accounts
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
/// deliverability findings (DL-20).
/// (AL-12), the tenant's slice of the data inventory, its own domains'
/// deliverability findings (DL-20), and its own scheduled reports (RP-22).
const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
@@ -88,6 +90,8 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockDestroy,
Permission::SysComplianceGet,
Permission::SysDeliverabilityGet,
Permission::SysScheduledReportGet,
Permission::SysScheduledReportUpdate,
];
#[derive(Clone, Copy, PartialEq, Eq)]
+3
View File
@@ -28,6 +28,9 @@ zip = "8.6"
quick-xml = "0.41"
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-builder = { version = "1.0" }
# inbuxa: scheduled reports run at a local time (scheduled-reports spec, RP-15)
chrono = { version = "0.4", default-features = false, features = ["std"] }
chrono-tz = "0.10"
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+1
View File
@@ -28,6 +28,7 @@ pub mod lock;
pub mod mailflow;
pub mod masked_email;
pub mod privacy;
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest (not a rebuild)
pub mod security;
pub mod tenancy;
pub mod undelete;
@@ -0,0 +1,621 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Scheduled reports and the weekly digest (scheduled-reports spec).
//!
//! An administrator picks sections, a schedule in a time zone and who gets
//! it; the server builds the report at that time from data it already keeps
//! and mails it. The weekly digest is a built-in report (RP-21).
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `S`, then one byte for the kind:
//!
//! - `r` + report id (u64): a report, as JSON.
//! - `s`: the settings, as JSON.
//!
//! Numbers are big-endian.
use chrono::{Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const FEATURE: u8 = b'S';
const KIND_REPORT: u8 = b'r';
const KIND_SETTINGS: u8 = b's';
/// The weekly digest's id (RP-21); other reports count up from here.
pub const DIGEST_ID: u64 = 1;
/// RP-23.
pub const MAX_RECIPIENTS: usize = 50;
/// RP-16: runs kept per report.
pub const KEEP_RUNS: usize = 20;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerdeSerialize, SerdeDeserialize,
)]
#[serde(rename_all = "camelCase")]
pub enum Section {
MailFlow,
Queue,
Spoofing,
TlsFailures,
Deliverability,
Security,
Storage,
Certificates,
}
impl Section {
pub const ALL: [Section; 8] = [
Section::MailFlow,
Section::Queue,
Section::Spoofing,
Section::TlsFailures,
Section::Deliverability,
Section::Security,
Section::Storage,
Section::Certificates,
];
pub fn as_str(&self) -> &'static str {
match self {
Section::MailFlow => "mailFlow",
Section::Queue => "queue",
Section::Spoofing => "spoofing",
Section::TlsFailures => "tlsFailures",
Section::Deliverability => "deliverability",
Section::Security => "security",
Section::Storage => "storage",
Section::Certificates => "certificates",
}
}
pub fn parse(value: &str) -> Option<Self> {
Section::ALL.into_iter().find(|s| s.as_str() == value)
}
/// RP-12: what a tenant's report leaves out, being server-wide.
pub fn server_wide(&self) -> bool {
matches!(
self,
Section::MailFlow | Section::Queue | Section::Security | Section::Certificates
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Frequency {
Daily,
#[default]
Weekly,
Monthly,
}
/// RP-15.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Schedule {
pub frequency: Frequency,
/// 1 = Monday … 7 = Sunday; weekly only.
pub weekday: u8,
/// 1–28; monthly only.
pub day_of_month: u8,
pub hour: u8,
pub minute: u8,
/// An IANA zone, e.g. "Europe/Amsterdam".
pub time_zone: String,
}
impl Default for Schedule {
fn default() -> Self {
Schedule {
frequency: Frequency::Weekly,
weekday: 1,
day_of_month: 1,
hour: 7,
minute: 0,
time_zone: "UTC".into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RunStatus {
#[default]
Sent,
Failed,
}
/// One time a report went, or tried to (RP-16).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Run {
pub at: u64,
pub by_hand: bool,
pub status: RunStatus,
pub reason: Option<String>,
pub recipients: u32,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Report {
pub id: u64,
pub name: String,
pub enabled: bool,
/// The weekly digest: can be edited or turned off, not deleted (RP-21).
pub built_in: bool,
pub sections: Vec<Section>,
pub schedule: Schedule,
/// Addresses of accounts on this server (RP-23). The digest's are the
/// system administrators' at send time, and this stays empty.
pub recipients: Vec<String>,
pub attach_csv: bool,
/// RP-22: a tenant's report, limited as RP-12 says.
pub tenant_id: Option<u32>,
pub created_at: u64,
/// The due time of the last run, so a run is never repeated (RP-16).
pub last_due: u64,
pub runs: Vec<Run>,
/// RP-5: what was failing at the last run, to say what changed.
pub failing: Vec<String>,
/// RP-17: scheduled runs that failed in a row.
pub failed_in_a_row: u32,
}
impl Report {
/// The weekly digest as it starts (RP-21, Decision 1: on).
pub fn digest(now: u64) -> Self {
Report {
id: DIGEST_ID,
name: "Weekly digest".into(),
enabled: true,
built_in: true,
sections: Section::ALL.to_vec(),
schedule: Schedule::default(),
recipients: Vec::new(),
attach_csv: false,
tenant_id: None,
created_at: now,
last_due: now,
runs: Vec::new(),
failing: Vec::new(),
failed_in_a_row: 0,
}
}
/// The sections this report covers, less the server-wide ones for a
/// tenant (RP-12).
pub fn effective_sections(&self) -> Vec<Section> {
self.sections
.iter()
.copied()
.filter(|s| self.tenant_id.is_none() || !s.server_wide())
.collect()
}
pub fn push_run(&mut self, run: Run) {
self.runs.insert(0, run);
self.runs.truncate(KEEP_RUNS);
}
/// What an administrator may set, checked (RP-15, RP-23). Whether the
/// recipients are local accounts is checked against the directory.
pub fn validate(&self) -> Result<(), &'static str> {
let name = self.name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err("A name of 1 to 100 characters.");
}
if self.sections.is_empty() {
return Err("At least one section.");
}
let mut seen = self.sections.clone();
seen.sort();
seen.dedup();
if seen.len() != self.sections.len() {
return Err("Each section once.");
}
self.schedule.validate()?;
if !self.built_in && self.recipients.is_empty() {
return Err("At least one recipient.");
}
if self.recipients.len() > MAX_RECIPIENTS {
return Err("At most 50 recipients.");
}
if self
.recipients
.iter()
.any(|r| r.trim().is_empty() || !r.contains('@'))
{
return Err("Recipients are email addresses.");
}
Ok(())
}
}
impl Schedule {
pub fn validate(&self) -> Result<(), &'static str> {
if self.time_zone.parse::<Tz>().is_err() {
return Err("An IANA time zone, such as Europe/Amsterdam.");
}
if self.hour > 23 || self.minute > 59 {
return Err("A time between 00:00 and 23:59.");
}
match self.frequency {
Frequency::Weekly if !(1..=7).contains(&self.weekday) => {
Err("A weekday from 1 (Monday) to 7 (Sunday).")
}
Frequency::Monthly if !(1..=28).contains(&self.day_of_month) => {
Err("A day of the month from 1 to 28.")
}
_ => Ok(()),
}
}
fn tz(&self) -> Tz {
self.time_zone.parse().unwrap_or(chrono_tz::UTC)
}
fn matches(&self, date: NaiveDate) -> bool {
match self.frequency {
Frequency::Daily => true,
Frequency::Weekly => date.weekday().number_from_monday() == self.weekday as u32,
Frequency::Monthly => date.day() == self.day_of_month as u32,
}
}
/// The schedule's instant on a local date. A time skipped by a clock
/// change goes at the first moment after it; a repeated one, the first time.
fn instant_on(&self, date: NaiveDate) -> Option<i64> {
let tz = self.tz();
let local = date.and_hms_opt(self.hour as u32, self.minute as u32, 0)?;
match tz.from_local_datetime(&local) {
LocalResult::Single(t) => Some(t.timestamp()),
LocalResult::Ambiguous(first, _) => Some(first.timestamp()),
LocalResult::None => (1..=4).find_map(|h| {
tz.from_local_datetime(&(local + Duration::minutes(30 * h)))
.earliest()
.map(|t| t.timestamp())
}),
}
}
/// The first scheduled instant strictly after `after` (Unix seconds).
pub fn next_due(&self, after: u64) -> Option<u64> {
let tz = self.tz();
let start = Utc
.timestamp_opt(after as i64, 0)
.single()?
.with_timezone(&tz)
.date_naive();
(0..62)
.filter_map(|d| start.checked_add_signed(Duration::days(d)))
.filter(|date| self.matches(*date))
.filter_map(|date| self.instant_on(date))
.find(|ts| *ts > after as i64)
.map(|ts| ts as u64)
}
/// The period a run due at `due` covers: the day, week or month before it.
pub fn period(&self, due: u64) -> (u64, u64) {
let from = match self.frequency {
Frequency::Daily => due.saturating_sub(86_400),
Frequency::Weekly => due.saturating_sub(7 * 86_400),
Frequency::Monthly => {
let tz = self.tz();
Utc.timestamp_opt(due as i64, 0)
.single()
.map(|t| t.with_timezone(&tz).date_naive())
.and_then(|date| date.checked_sub_months(chrono::Months::new(1)))
.and_then(|date| self.instant_on(date))
.map(|ts| ts as u64)
.unwrap_or(due.saturating_sub(30 * 86_400))
}
};
(from, due)
}
}
/// "Sep 29 – Oct 5, 2026": a period ends at its due time, so the last day
/// covered is the one before.
pub fn period_label(from: u64, to: u64) -> String {
let fmt = |ts: u64, year: bool| {
Utc.timestamp_opt(ts as i64, 0)
.single()
.map(|t| {
t.format(if year { "%b %-d, %Y" } else { "%b %-d" })
.to_string()
})
.unwrap_or_default()
};
format!("{} – {}", fmt(from, false), fmt(to.saturating_sub(1), true))
}
/// RP-20.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Settings {
/// Empty means "inbuxa reports".
pub from_name: Option<String>,
/// Empty means postmaster at the server's default domain.
pub from_address: Option<String>,
}
impl Settings {
pub fn from_name(&self) -> &str {
self.from_name
.as_deref()
.filter(|n| !n.trim().is_empty())
.unwrap_or("inbuxa reports")
}
}
// --- Storage --------------------------------------------------------------
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize a scheduled report")
.reason(err)
})
}
}
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid scheduled report")
.reason(err)
})
}
}
fn class(kind: u8, id: Option<u64>) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(kind);
if let Some(id) = id {
key.extend_from_slice(&id.to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
pub async fn report(data: &Store, id: u64) -> trc::Result<Option<Report>> {
Ok(data
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(id))))
.await
.caused_by(trc::location!())?
.map(|Json(report)| report))
}
/// Every report, by id.
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
let mut out = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(KIND_REPORT, Some(0))),
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
),
|_, value| {
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
out.push(report);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
out.sort_by_key(|r| r.id);
Ok(out)
}
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_REPORT, Some(report.id)),
Json(report).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn delete_report(data: &Store, id: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(KIND_REPORT, Some(id)));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
/// The id for a new report: one above the highest.
pub fn next_id(reports: &[Report]) -> u64 {
reports
.iter()
.map(|r| r.id)
.max()
.unwrap_or(DIGEST_ID)
.max(DIGEST_ID)
+ 1
}
/// Every server has the digest (RP-21); written the first time it's missed.
pub async fn ensure_digest(data: &Store, now: u64) -> trc::Result<()> {
if report(data, DIGEST_ID).await?.is_none() {
put_report(data, &Report::digest(now)).await?;
}
Ok(())
}
pub async fn settings(data: &Store) -> trc::Result<Settings> {
Ok(data
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(s: &str) -> u64 {
chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64
}
fn weekly(tz: &str) -> Schedule {
Schedule {
time_zone: tz.into(),
..Schedule::default()
}
}
#[test]
fn weekly_goes_monday_at_seven_local() {
let s = weekly("Europe/Amsterdam");
// Wednesday 2026-10-07 → Monday 2026-10-12 07:00 CEST (05:00Z)
assert_eq!(
s.next_due(ts("2026-10-07T12:00:00Z")),
Some(ts("2026-10-12T05:00:00Z"))
);
// Exactly at the due time: the next one, a week on
assert_eq!(
s.next_due(ts("2026-10-12T05:00:00Z")),
Some(ts("2026-10-19T05:00:00Z"))
);
// After the clocks go back (25 Oct): 07:00 CET is 06:00Z
assert_eq!(
s.next_due(ts("2026-10-20T00:00:00Z")),
Some(ts("2026-10-26T06:00:00Z"))
);
}
#[test]
fn daily_and_monthly() {
let daily = Schedule {
frequency: Frequency::Daily,
hour: 23,
minute: 30,
..weekly("UTC")
};
assert_eq!(
daily.next_due(ts("2026-10-06T23:30:00Z")),
Some(ts("2026-10-07T23:30:00Z"))
);
let monthly = Schedule {
frequency: Frequency::Monthly,
day_of_month: 28,
..weekly("America/Phoenix")
};
// 28 Oct 07:00 MST (no DST in Phoenix) = 14:00Z
assert_eq!(
monthly.next_due(ts("2026-10-06T00:00:00Z")),
Some(ts("2026-10-28T14:00:00Z"))
);
// Its period is the month before
assert_eq!(
monthly.period(ts("2026-10-28T14:00:00Z")),
(ts("2026-09-28T14:00:00Z"), ts("2026-10-28T14:00:00Z"))
);
}
#[test]
fn a_time_the_clocks_skip_goes_just_after() {
let s = Schedule {
frequency: Frequency::Daily,
hour: 2,
minute: 30,
..weekly("Europe/Amsterdam")
};
// 29 Mar 2026: 02:00–03:00 doesn't exist; 03:00 CEST = 01:00Z
assert_eq!(
s.next_due(ts("2026-03-28T12:00:00Z")),
Some(ts("2026-03-29T01:00:00Z"))
);
}
#[test]
fn validation() {
let mut r = Report {
name: "Ops".into(),
sections: vec![Section::Storage],
recipients: vec!["[email protected]".into()],
..Report::default()
};
assert_eq!(r.validate(), Ok(()));
r.schedule.time_zone = "Mars/Olympus".into();
assert!(r.validate().is_err());
r.schedule.time_zone = "UTC".into();
r.recipients.clear();
assert!(r.validate().is_err());
r.recipients = vec!["[email protected]".into(); 51];
assert!(r.validate().is_err());
r.recipients = vec!["[email protected]".into()];
r.sections = vec![Section::Storage, Section::Storage];
assert!(r.validate().is_err());
// The digest needs no recipients of its own
assert_eq!(Report::digest(0).validate(), Ok(()));
}
#[test]
fn tenants_lose_the_server_wide_sections() {
let mut r = Report::digest(0);
r.tenant_id = Some(3);
assert_eq!(
r.effective_sections(),
vec![
Section::Spoofing,
Section::TlsFailures,
Section::Deliverability,
Section::Storage
]
);
}
#[test]
fn ids_and_runs() {
assert_eq!(next_id(&[]), 2);
assert_eq!(next_id(&[Report::digest(0)]), 2);
let mut r = Report::digest(0);
for at in 0..30 {
r.push_run(Run {
at,
..Run::default()
});
}
assert_eq!(r.runs.len(), KEEP_RUNS);
assert_eq!(r.runs[0].at, 29);
}
}
@@ -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
}
}
@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ScheduledReport/get` and `/set` under `urn:inbuxa:jmap`: reports
//! the server builds and mails on a schedule, the weekly digest among them
//! (scheduled-reports spec).
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 ScheduledReport;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScheduledReportProperty {
Id,
Name,
Enabled,
BuiltIn,
Sections,
Schedule,
Recipients,
AttachCsv,
MemberTenantId,
CreatedAt,
NextRunAt,
Runs,
SendNow,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScheduledReportValue {
Id(Id),
}
impl Property for ScheduledReportProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside objects (the schedule, a run) stay plain keys
match parent {
None => ScheduledReportProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ScheduledReportProperty::Id => "id",
ScheduledReportProperty::Name => "name",
ScheduledReportProperty::Enabled => "enabled",
ScheduledReportProperty::BuiltIn => "builtIn",
ScheduledReportProperty::Sections => "sections",
ScheduledReportProperty::Schedule => "schedule",
ScheduledReportProperty::Recipients => "recipients",
ScheduledReportProperty::AttachCsv => "attachCsv",
ScheduledReportProperty::MemberTenantId => "memberTenantId",
ScheduledReportProperty::CreatedAt => "createdAt",
ScheduledReportProperty::NextRunAt => "nextRunAt",
ScheduledReportProperty::Runs => "runs",
ScheduledReportProperty::SendNow => "sendNow",
}
.into()
}
}
impl ScheduledReportProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => ScheduledReportProperty::Id,
b"name" => ScheduledReportProperty::Name,
b"enabled" => ScheduledReportProperty::Enabled,
b"builtIn" => ScheduledReportProperty::BuiltIn,
b"sections" => ScheduledReportProperty::Sections,
b"schedule" => ScheduledReportProperty::Schedule,
b"recipients" => ScheduledReportProperty::Recipients,
b"attachCsv" => ScheduledReportProperty::AttachCsv,
b"memberTenantId" => ScheduledReportProperty::MemberTenantId,
b"createdAt" => ScheduledReportProperty::CreatedAt,
b"nextRunAt" => ScheduledReportProperty::NextRunAt,
b"runs" => ScheduledReportProperty::Runs,
b"sendNow" => ScheduledReportProperty::SendNow,
)
}
}
impl FromStr for ScheduledReportProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
ScheduledReportProperty::parse(s).ok_or(())
}
}
impl Element for ScheduledReportValue {
type Property = ScheduledReportProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(ScheduledReportProperty::Id) => {
Id::from_str(value).ok().map(ScheduledReportValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ScheduledReportValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for ScheduledReport {
type Property = ScheduledReportProperty;
type Element = ScheduledReportValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = ScheduledReportProperty::Id;
}
impl From<Id> for ScheduledReportValue {
fn from(id: Id) -> Self {
ScheduledReportValue::Id(id)
}
}
impl JmapObjectId for ScheduledReportValue {
fn as_id(&self) -> Option<Id> {
match self {
ScheduledReportValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
ScheduledReportValue::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 = ScheduledReportValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for ScheduledReportProperty {
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
}
}
@@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ScheduledReportSettings/get` and `/set` under `urn:inbuxa:jmap`:
//! who scheduled reports come from (scheduled-reports spec, RP-20).
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 ScheduledReportSettings;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScheduledReportSettingsProperty {
Id,
FromName,
FromAddress,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ScheduledReportSettingsValue {
Id(Id),
}
impl Property for ScheduledReportSettingsProperty {
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
// Keys inside objects (the schedule, a run) stay plain keys
match parent {
None => ScheduledReportSettingsProperty::parse(value),
Some(_) => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ScheduledReportSettingsProperty::Id => "id",
ScheduledReportSettingsProperty::FromName => "fromName",
ScheduledReportSettingsProperty::FromAddress => "fromAddress",
}
.into()
}
}
impl ScheduledReportSettingsProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => ScheduledReportSettingsProperty::Id,
b"fromName" => ScheduledReportSettingsProperty::FromName,
b"fromAddress" => ScheduledReportSettingsProperty::FromAddress,
)
}
}
impl FromStr for ScheduledReportSettingsProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
ScheduledReportSettingsProperty::parse(s).ok_or(())
}
}
impl Element for ScheduledReportSettingsValue {
type Property = ScheduledReportSettingsProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(ScheduledReportSettingsProperty::Id) => Id::from_str(value)
.ok()
.map(ScheduledReportSettingsValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ScheduledReportSettingsValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for ScheduledReportSettings {
type Property = ScheduledReportSettingsProperty;
type Element = ScheduledReportSettingsValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = ScheduledReportSettingsProperty::Id;
}
impl From<Id> for ScheduledReportSettingsValue {
fn from(id: Id) -> Self {
ScheduledReportSettingsValue::Id(id)
}
}
impl JmapObjectId for ScheduledReportSettingsValue {
fn as_id(&self) -> Option<Id> {
match self {
ScheduledReportSettingsValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
ScheduledReportSettingsValue::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 = ScheduledReportSettingsValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for ScheduledReportSettingsProperty {
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
}
}
+3
View File
@@ -33,6 +33,9 @@ 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
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
pub mod inbuxa_held_message; // inbuxa: mail held for review
+9
View File
@@ -97,6 +97,15 @@ 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)
}
GetResponseMethod::ScheduledReportSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Journal(response) => {
response.eval_jptr(path, &mut results)
}
@@ -58,6 +58,9 @@ 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)?,
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
@@ -148,6 +151,15 @@ 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)?
}
SetRequestMethod::ScheduledReportSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Journal(request) => {
request.resolve_references(self, 1, false)?
}
+21
View File
@@ -73,6 +73,9 @@ pub enum MethodObject {
// inbuxa: the deliverability check
DeliverabilityReport,
DeliverabilitySettings,
ReportExport,
ScheduledReport,
ScheduledReportSettings,
HeldMessage,
// inbuxa: journaling
Journal,
@@ -124,6 +127,9 @@ impl MethodObject {
| MethodObject::HeldMessage
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ReportExport
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
@@ -332,6 +338,12 @@ 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",
(MethodFunction::Set, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/set",
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
@@ -510,6 +522,12 @@ 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),
"inbuxa:ScheduledReportSettings/set" => (MethodObject::ScheduledReportSettings, MethodFunction::Set),
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
@@ -595,6 +613,9 @@ 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",
MethodObject::JournalEntry => "inbuxa:JournalEntry",
MethodObject::JournalExport => "inbuxa:JournalExport",
+6
View File
@@ -128,6 +128,9 @@ 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>>),
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
@@ -176,6 +179,9 @@ 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>>),
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
+42
View File
@@ -715,6 +715,48 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::ScheduledReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::ScheduledReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::ScheduledReportSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReportSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::ScheduledReportSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReportSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
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)),
+39
View File
@@ -115,6 +115,9 @@ 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>),
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
@@ -163,6 +166,9 @@ 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>>),
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -892,6 +898,39 @@ 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))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>) -> Self {
ResponseMethod::Set(SetResponseMethod::ScheduledReport(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::ScheduledReportSettings(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::ScheduledReportSettings(Box::new(value)))
}
}
// inbuxa: accepted security to-do items
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
+31
View File
@@ -127,6 +127,11 @@ impl JmapAuthorization for AccessToken {
// page that shows the findings, so they read the same way
GetRequestMethod::DeliverabilityReport(_)
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
// inbuxa: scheduled-reports spec; a tenant administrator sees
// their own tenant's reports (RP-22)
GetRequestMethod::ScheduledReport(_)
| 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,
@@ -356,6 +361,29 @@ impl JmapAuthorization for AccessToken {
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityUpdate,
),
// inbuxa: scheduled reports; Send now is an update (RP-18)
SetRequestMethod::ScheduledReport(s) => validate_set(
s,
self,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
),
SetRequestMethod::ScheduledReportSettings(s) => validate_set(
s,
self,
Permission::SysScheduledReportUpdate,
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,
@@ -529,6 +557,9 @@ impl JmapAuthorization for AccessToken {
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ReportExport
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::HeldMessage
| MethodObject::Journal
| MethodObject::JournalEntry
+93
View File
@@ -299,6 +299,15 @@ 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);
}
SetResponseMethod::ScheduledReportSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Journal(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -558,6 +567,25 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: scheduled reports
GetRequestMethod::ScheduledReport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::scheduled_reports::get_reports(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::ScheduledReportSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::scheduled_reports::get_settings(self, access_token, *req)
.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)?;
@@ -1086,6 +1114,71 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: scheduled reports; every change is in the audit log
SetRequestMethod::ScheduledReport(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_reports(
self,
access_token,
req,
))
},
)
.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(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| {
Box::pin(crate::inbuxa::scheduled_reports::set_settings(
self,
access_token,
req,
))
},
)
.await?
.into()
}
// inbuxa: DL-6; which lists are asked is in the audit log
SetRequestMethod::DeliverabilitySettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
+3
View File
@@ -434,6 +434,9 @@ impl IntermediateChangesResponse {
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ReportExport
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
+1
View File
@@ -13,6 +13,7 @@ pub mod legal_hold;
pub mod mail_rule;
pub mod security_acceptance;
pub mod deliverability; // inbuxa: the deliverability check
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest
pub mod journal;
pub mod journal_entry;
pub mod held_message;
+690
View File
@@ -0,0 +1,690 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings`
//! (scheduled-reports spec).
//!
//! Reading needs `sysScheduledReportGet`, changing (and Send now, RP-18)
//! `sysScheduledReportUpdate`. A tenant administrator sees and changes only
//! their own tenant's reports, and a report they create is their tenant's
//! (RP-22). The weekly digest can be changed or turned off, not deleted, and
//! its recipients are the system administrators (RP-21). Recipients must be
//! accounts on this server (RP-23).
use common::{Server, auth::AccessToken};
use inbuxa_features::scheduled_reports::{self as model, Report, RunStatus, Schedule, Section};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::{
inbuxa_report_export::{ReportExport, ReportExportProperty as X, ReportExportValue},
inbuxa_scheduled_report::{
ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue,
},
inbuxa_scheduled_report_settings::{
ScheduledReportSettings, ScheduledReportSettingsProperty as S,
ScheduledReportSettingsValue,
},
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Element, Key, Map, Property, Value};
use std::{borrow::Cow, str::FromStr};
use store::write::now;
use types::id::Id;
const REPORT: &[R] = &[
R::Id,
R::Name,
R::Enabled,
R::BuiltIn,
R::Sections,
R::Schedule,
R::Recipients,
R::AttachCsv,
R::MemberTenantId,
R::CreatedAt,
R::NextRunAt,
R::Runs,
];
const SETTINGS: &[S] = &[S::Id, S::FromName, S::FromAddress];
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
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<P: Property, E: Element>(seconds: u64) -> Value<'static, P, E> {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
/// Whether this administrator may see or change the report (RP-22).
fn visible(access_token: &AccessToken, report: &Report) -> bool {
match access_token.tenant_id() {
Some(tenant) => report.tenant_id == Some(tenant),
None => true,
}
}
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, ScheduledReportValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
R::Id => Value::Element(ScheduledReportValue::Id(Id::from(report.id))),
R::Name => Value::Str(report.name.clone().into()),
R::Enabled => Value::Bool(report.enabled),
R::BuiltIn => Value::Bool(report.built_in),
R::Sections => Value::Array(
report
.sections
.iter()
.map(|s| Value::Str(s.as_str().into()))
.collect(),
),
R::Schedule => {
json_to_value(serde_json::to_value(&report.schedule).unwrap_or_default())
}
R::Recipients => Value::Array(
report
.recipients
.iter()
.map(|r| Value::Str(r.clone().into()))
.collect(),
),
R::AttachCsv => Value::Bool(report.attach_csv),
R::MemberTenantId => report
.tenant_id
.map(|t| Value::Element(ScheduledReportValue::Id(Id::from(t))))
.unwrap_or(Value::Null),
R::CreatedAt => date(report.created_at),
R::NextRunAt => report
.enabled
.then(|| report.schedule.next_due(report.last_due))
.flatten()
.map(date)
.unwrap_or(Value::Null),
R::Runs => Value::Array(
report
.runs
.iter()
.map(|run| {
json_to_value(serde_json::json!({
"at": UTCDate::from_timestamp(run.at as i64).to_string(),
"byHand": run.by_hand,
"status": match run.status {
RunStatus::Sent => "sent",
RunStatus::Failed => "failed",
},
"reason": run.reason,
"recipients": run.recipients,
"size": run.size,
}))
})
.collect(),
),
R::SendNow => Value::Null,
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:ScheduledReport/get`.
pub async fn get_reports(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<ScheduledReport>,
) -> trc::Result<GetResponse<ScheduledReport>> {
let properties = request.unwrap_properties(REPORT);
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,
};
model::ensure_digest(server.store(), now()).await?;
let reports: Vec<Report> = model::reports(server.store())
.await?
.into_iter()
.filter(|r| visible(access_token, r))
.collect();
match ids {
None => {
response.list = reports
.iter()
.map(|r| report_value(r, &properties))
.collect();
}
Some(ids) => {
for id in ids {
match reports.iter().find(|r| r.id == id.id()) {
Some(report) => response.list.push(report_value(report, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
/// What a create or an update may set, applied onto `report`. Returns
/// whether Send now was asked for.
fn apply(
report: &mut Report,
value: Value<'_, R, ScheduledReportValue>,
) -> Result<bool, SetError<R>> {
let invalid = |property: R, why: &str| {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
};
let mut send_now = false;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = key else {
return Err(SetError::invalid_properties().with_property(key.into_owned()));
};
let json = serde_json::to_value(&value).unwrap_or_default();
match property {
R::Name => match json.as_str() {
Some(name) => report.name = name.trim().to_string(),
None => return Err(invalid(R::Name, "A name.")),
},
R::Enabled => match json.as_bool() {
Some(enabled) => report.enabled = enabled,
None => return Err(invalid(R::Enabled, "true or false.")),
},
R::AttachCsv => match json.as_bool() {
Some(attach) => report.attach_csv = attach,
None => return Err(invalid(R::AttachCsv, "true or false.")),
},
R::Sections => {
let sections = json.as_array().and_then(|items| {
items
.iter()
.map(|i| i.as_str().and_then(Section::parse))
.collect::<Option<Vec<_>>>()
});
match sections {
Some(sections) => report.sections = sections,
None => return Err(invalid(R::Sections, "A list of section names.")),
}
}
R::Schedule => match serde_json::from_value::<Schedule>(json) {
Ok(schedule) => report.schedule = schedule,
Err(_) => return Err(invalid(R::Schedule, "A schedule.")),
},
R::Recipients => {
let recipients = json.as_array().and_then(|items| {
items
.iter()
.map(|i| i.as_str().map(|s| s.trim().to_lowercase()))
.collect::<Option<Vec<_>>>()
});
match recipients {
Some(r) if report.built_in && !r.is_empty() => {
return Err(invalid(
R::Recipients,
"The weekly digest goes to the system administrators.",
));
}
Some(r) => report.recipients = r,
None => return Err(invalid(R::Recipients, "A list of addresses.")),
}
}
R::SendNow => send_now = json.as_bool().unwrap_or(false),
other => return Err(invalid(other, "The server sets this.")),
}
}
Ok(send_now)
}
/// RP-23: every recipient is an account on this server.
async fn check_recipients(server: &Server, report: &Report) -> trc::Result<Option<String>> {
for address in &report.recipients {
if server.rcpt_id_from_email(address).await?.is_none() {
return Ok(Some(format!(
"{address} isn't an account on this server. Reports only go to accounts here."
)));
}
}
Ok(None)
}
/// `inbuxa:ScheduledReport/set`.
pub async fn set_reports(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, ScheduledReport>,
) -> trc::Result<SetResponse<ScheduledReport>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let data = server.store();
model::ensure_digest(data, now()).await?;
for (client_id, value) in request.unwrap_create() {
let existing = model::reports(data).await?;
let mut report = Report {
id: model::next_id(&existing),
enabled: true,
tenant_id: access_token.tenant_id(),
created_at: now(),
last_due: now(),
..Report::default()
};
let send_now = match apply(&mut report, value) {
Ok(send_now) => send_now,
Err(err) => {
response.not_created.append(client_id, err);
continue;
}
};
if let Err(why) = report.validate() {
response.not_created.append(
client_id,
SetError::invalid_properties().with_description(why),
);
continue;
}
if let Some(why) = check_recipients(server, &report).await? {
response.not_created.append(
client_id,
SetError::invalid_properties()
.with_property(R::Recipients)
.with_description(why),
);
continue;
}
model::put_report(data, &report).await?;
if send_now {
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
}
response
.created
.insert(client_id, report_value(&report, &[R::Id, R::NextRunAt]));
}
for (id, value) in request.unwrap_update().into_valid() {
let Some(mut report) = model::report(data, id.id())
.await?
.filter(|r| visible(access_token, r))
else {
response.not_updated.append(id, SetError::not_found());
continue;
};
let schedule_before = report.schedule.clone();
let send_now = match apply(&mut report, value) {
Ok(send_now) => send_now,
Err(err) => {
response.not_updated.append(id, err);
continue;
}
};
if let Err(why) = report.validate() {
response
.not_updated
.append(id, SetError::invalid_properties().with_description(why));
continue;
}
if let Some(why) = check_recipients(server, &report).await? {
response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(R::Recipients)
.with_description(why),
);
continue;
}
// A new schedule counts from now, not from the last run
if report.schedule != schedule_before {
report.last_due = report.last_due.max(now());
}
model::put_report(data, &report).await?;
if send_now {
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
}
response.updated.append(id, None);
}
for id in request.unwrap_destroy().into_valid() {
match model::report(data, id.id())
.await?
.filter(|r| visible(access_token, r))
{
None => response.not_destroyed.append(id, SetError::not_found()),
Some(report) if report.built_in => response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("The weekly digest can be turned off, not deleted."),
),
Some(_) => {
model::delete_report(data, id.id()).await?;
response.destroyed.push(id);
}
}
}
Ok(response)
}
fn settings_value(
settings: &model::Settings,
default_address: &str,
properties: &[S],
) -> Value<'static, S, ScheduledReportSettingsValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
S::Id => Value::Element(ScheduledReportSettingsValue::Id(Id::singleton())),
S::FromName => Value::Str(settings.from_name().to_string().into()),
S::FromAddress => Value::Str(
settings
.from_address
.clone()
.unwrap_or_else(|| default_address.to_string())
.into(),
),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
fn default_address(server: &Server) -> String {
format!("postmaster@{}", server.core.email.default_domain_name)
}
/// `inbuxa:ScheduledReportSettings/get`.
pub async fn get_settings(
server: &Server,
_access_token: &AccessToken,
mut request: GetRequest<ScheduledReportSettings>,
) -> trc::Result<GetResponse<ScheduledReportSettings>> {
let properties = request.unwrap_properties(SETTINGS);
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,
};
let settings = model::settings(server.store()).await?;
let default = default_address(server);
match ids {
None => response
.list
.push(settings_value(&settings, &default, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response
.list
.push(settings_value(&settings, &default, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
/// `inbuxa:ScheduledReportSettings/set`: the server's, not a tenant's.
pub async fn set_settings(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, ScheduledReportSettings>,
) -> trc::Result<SetResponse<ScheduledReportSettings>> {
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Who reports come from is the server's."));
}
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response
.not_created
.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
let data = server.store();
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() {
response.not_updated.append(id, SetError::not_found());
continue;
}
let mut settings = model::settings(data).await?;
let mut error = None;
for (key, value) in value.into_expanded_object() {
let json = serde_json::to_value(&value).unwrap_or_default();
match &key {
Key::Property(S::FromName) => {
settings.from_name = json
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
Key::Property(S::FromAddress) => {
match json.as_str().map(|s| s.trim().to_lowercase()) {
Some(a) if a.is_empty() => settings.from_address = None,
Some(a) if a.contains('@') && !a.ends_with('@') => {
settings.from_address = Some(a)
}
_ => {
error = Some(
SetError::invalid_properties()
.with_property(S::FromAddress)
.with_description("An email address."),
);
break;
}
}
}
Key::Property(property) => {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description("The server sets this."),
);
break;
}
_ => {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
}
}
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
model::put_settings(data, &settings).await?;
response.updated.append(id, None);
}
}
}
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)
}
+3
View File
@@ -1766,6 +1766,9 @@ pub enum Permission {
SysDeliverabilityGet = 685,
SysDeliverabilityUpdate = 686,
SysDeliverabilityCheck = 687,
// inbuxa: scheduled reports and the weekly digest
SysScheduledReportGet = 688,
SysScheduledReportUpdate = 689,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+7 -1
View File
@@ -7105,6 +7105,8 @@ impl EnumImpl for Permission {
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
b"sysScheduledReportGet" => Permission::SysScheduledReportGet,
b"sysScheduledReportUpdate" => Permission::SysScheduledReportUpdate,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7809,6 +7811,8 @@ impl EnumImpl for Permission {
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
Permission::SysScheduledReportGet => "sysScheduledReportGet",
Permission::SysScheduledReportUpdate => "sysScheduledReportUpdate",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8506,6 +8510,8 @@ impl EnumImpl for Permission {
685 => Some(Permission::SysDeliverabilityGet),
686 => Some(Permission::SysDeliverabilityUpdate),
687 => Some(Permission::SysDeliverabilityCheck),
688 => Some(Permission::SysScheduledReportGet),
689 => Some(Permission::SysScheduledReportUpdate),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate),
@@ -8950,7 +8956,7 @@ impl EnumImpl for Permission {
}
}
const COUNT: usize = 688;
const COUNT: usize = 690;
}
impl serde::Serialize for Permission {
File diff suppressed because it is too large. Load diff
+4
View File
@@ -27,6 +27,7 @@ pub mod broadcast;
pub mod inbuxa_lock_expiry;
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
pub mod inbuxa_scheduled_reports; // inbuxa: scheduled reports and the weekly digest
pub mod state_manager;
pub mod task_manager;
@@ -78,6 +79,9 @@ impl SpawnServices for IpcReceivers {
// inbuxa: deliverability spec, DL-14: each node checks itself daily
inbuxa_deliverability::spawn_deliverability(inner.clone());
// inbuxa: scheduled-reports spec, RP-16: every node looks for due reports
inbuxa_scheduled_reports::spawn_scheduled_reports(inner.clone());
// Spawn task scheduler
spawn_task_scheduler(inner);
}
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: a server-built message, always DKIM-signed (scheduled-reports
//! spec, RP-14). Unlike `send_autogenerated`, a message that can't be signed
//! isn't sent, and the caller hears why.
use crate::queue::{
MessageSource,
spool::{QueueParams, SmtpSpool},
};
use common::Server;
/// Queues `raw` from `from` to `rcpts`, signed with `sign_domain`'s keys.
pub async fn send_signed(
server: &Server,
from: &str,
rcpts: &[String],
raw: &[u8],
sign_domain: &str,
) -> Result<(), String> {
let signers = match server.dkim_signers(sign_domain).await {
Ok(Some(signers)) => signers,
Ok(None) => return Err(format!("{sign_domain} has no DKIM key to sign with.")),
Err(err) => {
trc::error!(err.details("Failed to retrieve DKIM signers for a report"));
return Err(format!("The DKIM keys for {sign_domain} couldn't be read."));
}
};
let mut message = server.new_message(from, MessageSource::Autogenerated, 0);
for rcpt in rcpts {
message.add_expanded_recipient(rcpt, server).await;
}
if message
.queue(QueueParams::new(raw, 0, server).with_dkim_signers(Some(signers)))
.await
{
Ok(())
} else {
Err("The mail queue didn't accept the message.".into())
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod dkim;
pub mod dmarc;
pub mod inbound;
pub mod index;
pub mod inbuxa_send; // inbuxa: signed server-built mail (scheduled-reports spec, RP-14)
pub mod scheduler;
pub mod send;
pub mod shared; // inbuxa: reports written by every node
+2
View File
@@ -377,6 +377,8 @@ 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`, `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
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
+42
View File
@@ -178,6 +178,36 @@ default = "none"
file = "inbuxa_deliverability_settings.rs"
default = "none"
[object."inbuxa:ScheduledReport"]
file = "inbuxa_scheduled_report.rs"
default = "none"
whose = ["administrator"]
where = ["data-store"]
scope = "tenant"
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"
whose = ["administrator"]
where = ["data-store"]
scope = "server"
retention = "unbounded"
[object."inbuxa:ScheduledReportSettings".properties]
fromAddress = ["contact"]
[object."inbuxa:LegalHold"]
file = "inbuxa_legal_hold.rs"
default = "none"
@@ -298,6 +328,18 @@ captures = ["x:DataRetention.expungeTrashAfter", "x:DataRetention.expungeSubmiss
leaves_host = false
written_by = ["crates/email/src/message/ingest.rs", "crates/groupware/src/calendar/storage.rs", "crates/groupware/src/contact/storage.rs", "crates/groupware/src/file/storage.rs"]
# Scheduled reports are built when they're sent and not stored; the mail
# lands in administrators' own mailboxes on this server (scheduled-reports
# spec, RP-23), so it doesn't leave the host.
[source."scheduled-report-mail"]
categories = ["contact", "network", "metadata"]
whose = ["holder", "correspondent", "administrator"]
where = ["data-store", "blob-store"]
scope = "tenant"
retention = "receiver"
leaves_host = false
written_by = ["crates/services/src/inbuxa_scheduled_reports.rs"]
[source."full-text-index"]
categories = ["content", "identifier", "contact", "metadata"]
whose = ["holder", "correspondent"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U
F0Ba3aWEThPbP2e1Uy6eryGOZ-UxeLsPNJn9y2yGp4Y
+1
View File
@@ -18,6 +18,7 @@ pub mod compliance; // inbuxa: the compliance roles
pub mod mail_rules; // inbuxa: DLP and mail flow rules
pub mod security_acceptances; // inbuxa: accepted security to-do items
pub mod deliverability; // inbuxa: the deliverability check
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest
pub mod journal; // inbuxa: journaling
pub mod audit; // inbuxa: the audit log
pub mod authorization;
+401
View File
@@ -0,0 +1,401 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Scheduled reports and the weekly digest (scheduled-reports spec): the
//! digest every server has, making and changing reports, who they may go
//! to, Send now, and what a tenant administrator sees.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
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(),
)
}
async fn reports(account: &Account) -> Vec<Value> {
let (_, response) = call(account, "inbuxa:ScheduledReport/get", json!({"ids": null})).await;
response["list"].as_array().cloned().unwrap_or_default()
}
pub async fn test(test: &mut TestServer) {
println!("Running scheduled reports tests...");
let admin = test.account("[email protected]");
let me = "[email protected]";
// --- The weekly digest every server has (RP-21) ------------------------
let list = reports(admin).await;
let digest = list
.iter()
.find(|r| r["builtIn"] == true)
.unwrap_or_else(|| panic!("no digest: {list:?}"));
let digest_id = digest["id"].as_str().unwrap().to_string();
assert_eq!(digest["enabled"], true, "{digest}");
assert_eq!(digest["sections"].as_array().unwrap().len(), 8, "{digest}");
assert_eq!(digest["schedule"]["frequency"], "weekly", "{digest}");
assert_eq!(digest["schedule"]["weekday"], 1, "{digest}");
assert_eq!(digest["schedule"]["hour"], 7, "{digest}");
assert!(digest["nextRunAt"].is_string(), "{digest}");
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"destroy": [digest_id]}),
)
.await;
assert!(
response["notDestroyed"][&digest_id].is_object(),
"the digest was deleted: {response}"
);
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"update": {&digest_id: {"recipients": [me]}}}),
)
.await;
assert!(
response["notUpdated"][&digest_id].is_object(),
"the digest took recipients: {response}"
);
// It can be changed and turned off
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"update": {&digest_id: {"enabled": false, "schedule": {
"frequency": "weekly", "weekday": 5, "hour": 16, "minute": 30,
"timeZone": "America/Phoenix"
}}}}),
)
.await;
assert!(
response["updated"][&digest_id].is_null() && response["notUpdated"].is_null(),
"{response}"
);
let digest = reports(admin)
.await
.into_iter()
.find(|r| r["id"] == digest_id.as_str())
.unwrap();
assert_eq!(digest["enabled"], false, "{digest}");
assert!(
digest["nextRunAt"].is_null(),
"an off report has no next run: {digest}"
);
assert_eq!(digest["schedule"]["timeZone"], "America/Phoenix");
// --- Making a report: what's checked (RP-15, RP-23) ---------------------
let good = json!({
"name": "Daily storage",
"sections": ["storage", "certificates"],
"schedule": {"frequency": "daily", "hour": 6, "minute": 0, "timeZone": "Europe/Amsterdam"},
"recipients": [me],
"attachCsv": true
});
let mut outside = good.clone();
outside["recipients"] = json!(["[email protected]"]);
let mut bad_zone = good.clone();
bad_zone["schedule"]["timeZone"] = json!("Mars/Olympus");
let mut no_sections = good.clone();
no_sections["sections"] = json!([]);
let mut unknown_section = good.clone();
unknown_section["sections"] = json!(["weather"]);
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"create": {
"outside": outside, "zone": bad_zone, "empty": no_sections,
"unknown": unknown_section, "good": good
}}),
)
.await;
for refused in ["outside", "zone", "empty", "unknown"] {
assert!(
response["notCreated"][refused].is_object(),
"{refused} was accepted: {response}"
);
}
assert!(
response["notCreated"]["outside"]["description"]
.as_str()
.unwrap_or_default()
.contains("isn't an account on this server"),
"{response}"
);
let id = response["created"]["good"]["id"]
.as_str()
.unwrap_or_else(|| panic!("not created: {response}"))
.to_string();
assert!(
response["created"]["good"]["nextRunAt"].is_string(),
"{response}"
);
// The server's own fields can't be set
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"update": {&id: {"runs": []}}}),
)
.await;
assert!(response["notUpdated"][&id].is_object(), "{response}");
// --- Send now (RP-18), never unsigned (RP-14) ----------------------------
async fn send_now(admin: &Account, id: &str, runs_before: usize) -> Value {
let (_, response) = call(
admin,
"inbuxa:ScheduledReport/set",
json!({"update": {id: {"sendNow": true}}}),
)
.await;
assert!(response["notUpdated"].is_null(), "{response}");
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let report = reports(admin)
.await
.into_iter()
.find(|r| r["id"] == id)
.unwrap();
let runs = report["runs"].as_array().cloned().unwrap_or_default();
if runs.len() > runs_before {
return runs[0].clone();
}
assert!(Instant::now() < deadline, "Send now never ran: {report}");
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
// The default sender's domain has no DKIM key: refused, with the reason
let run = send_now(admin, &id, 0).await;
assert_eq!(run["byHand"], true, "{run}");
assert_eq!(run["status"], "failed", "{run}");
assert!(
run["reason"].as_str().unwrap_or_default().contains("DKIM"),
"{run}"
);
// A domain with keys signs it, and the queue takes it
let (_, response) = call(
admin,
"x:Domain/query",
json!({"filter": {"name": "example.com"}}),
)
.await;
let domain_id = response["ids"][0]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.parse::<types::id::Id>()
.unwrap();
admin.create_dkim_signatures(domain_id).await;
let (_, response) = call(
admin,
"inbuxa:ScheduledReportSettings/set",
json!({"update": {"singleton": {"fromAddress": "[email protected]"}}}),
)
.await;
assert!(response["notUpdated"].is_null(), "{response}");
let run = send_now(admin, &id, 1).await;
assert_eq!(run["status"], "sent", "{run}");
assert_eq!(run["recipients"], 1, "{run}");
assert!(run["size"].as_u64().unwrap() > 500, "{run}");
// --- Who it comes from (RP-20) -------------------------------------------
let (_, response) = call(
admin,
"inbuxa:ScheduledReportSettings/get",
json!({"ids": null}),
)
.await;
let settings = &response["list"][0];
assert_eq!(settings["fromName"], "inbuxa reports", "{response}");
assert_eq!(settings["fromAddress"], "[email protected]", "{response}");
let (_, response) = call(
admin,
"inbuxa:ScheduledReportSettings/set",
json!({"update": {"singleton": {"fromAddress": "not an address"}}}),
)
.await;
assert!(
response["notUpdated"]["singleton"].is_object(),
"{response}"
);
// --- A tenant administrator (RP-22) --------------------------------------
let tenant = admin
.registry_create_object(Tenant {
name: "Reports tenant".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "reports.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(tenant),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let t_admin = admin
.create_user_account(
"[email protected]",
"tenant-admin-secret-6120",
"Tenant admin",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
t_admin.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
assert!(
reports(&t_admin).await.is_empty(),
"a tenant administrator saw the server's reports"
);
let (_, response) = call(
&t_admin,
"inbuxa:ScheduledReport/set",
json!({"create": {"mine": {
"name": "Our domains",
"sections": ["spoofing", "deliverability", "mailFlow"],
"schedule": {"frequency": "monthly", "dayOfMonth": 1, "hour": 8, "minute": 0, "timeZone": "UTC"},
"recipients": ["[email protected]"]
}}}),
)
.await;
let mine = response["created"]["mine"]["id"]
.as_str()
.unwrap_or_else(|| panic!("tenant report not created: {response}"))
.to_string();
let seen = reports(&t_admin).await;
assert_eq!(seen.len(), 1, "{seen:?}");
assert_eq!(seen[0]["memberTenantId"], tenant.to_string(), "{seen:?}");
// The system administrator sees it too, and the tenant can't touch theirs
assert!(
reports(admin)
.await
.iter()
.any(|r| r["id"] == mine.as_str())
);
let (_, response) = call(
&t_admin,
"inbuxa:ScheduledReport/set",
json!({"update": {&id: {"enabled": false}}}),
)
.await;
assert!(response["notUpdated"][&id].is_object(), "{response}");
let (name, response) = call(
&t_admin,
"inbuxa:ScheduledReportSettings/set",
json!({"update": {"singleton": {"fromName": "Tenant"}}}),
)
.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,
"inbuxa:ScheduledReport/set",
json!({"destroy": [&id, &mine]}),
)
.await;
assert_eq!(
response["destroyed"].as_array().map(|d| d.len()),
Some(2),
"{response}"
);
// Put the digest back as it was for the tests that follow
call(
admin,
"inbuxa:ScheduledReport/set",
json!({"update": {&digest_id: {"enabled": true, "schedule": {
"frequency": "weekly", "weekday": 1, "hour": 7, "minute": 0, "timeZone": "UTC"
}}}}),
)
.await;
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn scheduled_reports_tests() {
let mut test = TestServerBuilder::new("scheduled_reports_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}