Scheduled reports and the weekly digest #160
No files matched your search
Generated
+2
@@ -3959,6 +3959,8 @@ dependencies = [
|
|||||||
"ahash",
|
"ahash",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"chrono",
|
||||||
|
"chrono-tz",
|
||||||
"flate2",
|
"flate2",
|
||||||
"jmap_proto",
|
"jmap_proto",
|
||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
|
|||||||
@@ -317,6 +317,12 @@ impl Default for DefaultPermissions {
|
|||||||
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
|
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
|
||||||
default.superuser.push(permission);
|
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
|
// inbuxa: DLP and mail flow rules, and held mail, are the
|
||||||
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||||
// settled answer 3)
|
// settled answer 3)
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ use types::id::Id;
|
|||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (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
|
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
||||||
/// the data inventory (personal-data catalog spec), accepting security
|
/// the data inventory (personal-data catalog spec), accepting security
|
||||||
/// to-do items (security to-do list spec), and the deliverability check
|
/// to-do items (security to-do list spec), the deliverability check
|
||||||
/// (deliverability spec).
|
/// (deliverability spec), and scheduled reports (scheduled-reports spec).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -60,6 +60,8 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysDeliverabilityGet,
|
Permission::SysDeliverabilityGet,
|
||||||
Permission::SysDeliverabilityUpdate,
|
Permission::SysDeliverabilityUpdate,
|
||||||
Permission::SysDeliverabilityCheck,
|
Permission::SysDeliverabilityCheck,
|
||||||
|
Permission::SysScheduledReportGet,
|
||||||
|
Permission::SysScheduledReportUpdate,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the server-level Compliance Officer role once it exists:
|
/// 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
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
/// 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'
|
/// (AL-12), the tenant's slice of the data inventory, its own domains'
|
||||||
/// deliverability findings (DL-20).
|
/// deliverability findings (DL-20), and its own scheduled reports (RP-22).
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -88,6 +90,8 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
Permission::SysComplianceGet,
|
Permission::SysComplianceGet,
|
||||||
Permission::SysDeliverabilityGet,
|
Permission::SysDeliverabilityGet,
|
||||||
|
Permission::SysScheduledReportGet,
|
||||||
|
Permission::SysScheduledReportUpdate,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ zip = "8.6"
|
|||||||
quick-xml = "0.41"
|
quick-xml = "0.41"
|
||||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||||
mail-builder = { version = "1.0" }
|
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]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["macros", "rt"] }
|
tokio = { version = "1.53", features = ["macros", "rt"] }
|
||||||
@@ -28,6 +28,7 @@ pub mod lock;
|
|||||||
pub mod mailflow;
|
pub mod mailflow;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
pub mod privacy;
|
pub mod privacy;
|
||||||
|
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest (not a rebuild)
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
pub mod undelete;
|
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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ 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_security_acceptance; // inbuxa: accepted security to-do items
|
||||||
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
|
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
|
||||||
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
|
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
|
||||||
|
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; // inbuxa: journaling
|
||||||
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
||||||
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::DeliverabilitySettings(response) => {
|
GetResponseMethod::DeliverabilitySettings(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
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) => {
|
GetResponseMethod::Journal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeliverabilitySettings(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::Journal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
||||||
@@ -148,6 +150,12 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::DeliverabilitySettings(request) => {
|
SetRequestMethod::DeliverabilitySettings(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
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) => {
|
SetRequestMethod::Journal(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ pub enum MethodObject {
|
|||||||
// inbuxa: the deliverability check
|
// inbuxa: the deliverability check
|
||||||
DeliverabilityReport,
|
DeliverabilityReport,
|
||||||
DeliverabilitySettings,
|
DeliverabilitySettings,
|
||||||
|
ScheduledReport,
|
||||||
|
ScheduledReportSettings,
|
||||||
HeldMessage,
|
HeldMessage,
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
Journal,
|
Journal,
|
||||||
@@ -124,6 +126,8 @@ impl MethodObject {
|
|||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::DeliverabilityReport
|
| MethodObject::DeliverabilityReport
|
||||||
| MethodObject::DeliverabilitySettings
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::ScheduledReport
|
||||||
|
| MethodObject::ScheduledReportSettings
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
@@ -332,6 +336,10 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
|
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
|
||||||
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
|
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
|
||||||
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set",
|
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/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::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||||
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
||||||
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
||||||
@@ -510,6 +518,10 @@ impl MethodName {
|
|||||||
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
|
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
|
||||||
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
|
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
|
||||||
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set),
|
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, 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/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||||
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
||||||
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
||||||
@@ -595,6 +607,8 @@ impl Display for MethodObject {
|
|||||||
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||||
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
|
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
|
||||||
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
|
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
|
||||||
|
MethodObject::ScheduledReport => "inbuxa:ScheduledReport",
|
||||||
|
MethodObject::ScheduledReportSettings => "inbuxa:ScheduledReportSettings",
|
||||||
MethodObject::Journal => "inbuxa:Journal",
|
MethodObject::Journal => "inbuxa:Journal",
|
||||||
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
||||||
MethodObject::JournalExport => "inbuxa:JournalExport",
|
MethodObject::JournalExport => "inbuxa:JournalExport",
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ pub enum GetRequestMethod {
|
|||||||
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||||
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
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>>),
|
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||||
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
@@ -176,6 +178,8 @@ pub enum SetRequestMethod<'x> {
|
|||||||
),
|
),
|
||||||
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
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>>),
|
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
|
|||||||
@@ -715,6 +715,34 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ pub enum GetResponseMethod {
|
|||||||
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||||
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
||||||
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
||||||
|
ScheduledReport(GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>),
|
||||||
|
ScheduledReportSettings(GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>),
|
||||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||||
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||||
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||||
@@ -163,6 +165,8 @@ pub enum SetResponseMethod {
|
|||||||
),
|
),
|
||||||
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
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>>),
|
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
@@ -892,6 +896,28 @@ impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::Deliver
|
|||||||
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(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
|
// inbuxa: accepted security to-do items
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// page that shows the findings, so they read the same way
|
// page that shows the findings, so they read the same way
|
||||||
GetRequestMethod::DeliverabilityReport(_)
|
GetRequestMethod::DeliverabilityReport(_)
|
||||||
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
|
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
|
||||||
|
// inbuxa: scheduled-reports spec; a tenant administrator sees
|
||||||
|
// their own tenant's reports (RP-22)
|
||||||
|
GetRequestMethod::ScheduledReport(_)
|
||||||
|
| GetRequestMethod::ScheduledReportSettings(_) => Permission::SysScheduledReportGet,
|
||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -356,6 +360,21 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysDeliverabilityUpdate,
|
Permission::SysDeliverabilityUpdate,
|
||||||
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: LH-12, exporting held data
|
// inbuxa: LH-12, exporting held data
|
||||||
SetRequestMethod::HoldExport(s) => validate_set(
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -529,6 +548,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::SecurityAcceptance
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::DeliverabilityReport
|
| MethodObject::DeliverabilityReport
|
||||||
| MethodObject::DeliverabilitySettings
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::ScheduledReport
|
||||||
|
| MethodObject::ScheduledReportSettings
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::DeliverabilitySettings(set_response) => {
|
SetResponseMethod::DeliverabilitySettings(set_response) => {
|
||||||
set_response.update_created_ids(&mut 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) => {
|
SetResponseMethod::Journal(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -558,6 +564,19 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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()
|
||||||
|
}
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
GetRequestMethod::Journal(mut req) => {
|
GetRequestMethod::Journal(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -1086,6 +1105,49 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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()
|
||||||
|
}
|
||||||
|
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
|
// inbuxa: DL-6; which lists are asked is in the audit log
|
||||||
SetRequestMethod::DeliverabilitySettings(mut req) => {
|
SetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
|||||||
@@ -434,6 +434,8 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::SecurityAcceptance
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::DeliverabilityReport
|
| MethodObject::DeliverabilityReport
|
||||||
| MethodObject::DeliverabilitySettings
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::ScheduledReport
|
||||||
|
| MethodObject::ScheduledReportSettings
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod legal_hold;
|
|||||||
pub mod mail_rule;
|
pub mod mail_rule;
|
||||||
pub mod security_acceptance;
|
pub mod security_acceptance;
|
||||||
pub mod deliverability; // inbuxa: the deliverability check
|
pub mod deliverability; // inbuxa: the deliverability check
|
||||||
|
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod journal_entry;
|
pub mod journal_entry;
|
||||||
pub mod held_message;
|
pub mod held_message;
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
/*
|
||||||
|
* 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_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;
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1766,6 +1766,9 @@ pub enum Permission {
|
|||||||
SysDeliverabilityGet = 685,
|
SysDeliverabilityGet = 685,
|
||||||
SysDeliverabilityUpdate = 686,
|
SysDeliverabilityUpdate = 686,
|
||||||
SysDeliverabilityCheck = 687,
|
SysDeliverabilityCheck = 687,
|
||||||
|
// inbuxa: scheduled reports and the weekly digest
|
||||||
|
SysScheduledReportGet = 688,
|
||||||
|
SysScheduledReportUpdate = 689,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7105,6 +7105,8 @@ impl EnumImpl for Permission {
|
|||||||
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
|
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
|
||||||
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
|
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
|
||||||
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
|
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
|
||||||
|
b"sysScheduledReportGet" => Permission::SysScheduledReportGet,
|
||||||
|
b"sysScheduledReportUpdate" => Permission::SysScheduledReportUpdate,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7809,6 +7811,8 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
|
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
|
||||||
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
|
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
|
||||||
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
|
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
|
||||||
|
Permission::SysScheduledReportGet => "sysScheduledReportGet",
|
||||||
|
Permission::SysScheduledReportUpdate => "sysScheduledReportUpdate",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8506,6 +8510,8 @@ impl EnumImpl for Permission {
|
|||||||
685 => Some(Permission::SysDeliverabilityGet),
|
685 => Some(Permission::SysDeliverabilityGet),
|
||||||
686 => Some(Permission::SysDeliverabilityUpdate),
|
686 => Some(Permission::SysDeliverabilityUpdate),
|
||||||
687 => Some(Permission::SysDeliverabilityCheck),
|
687 => Some(Permission::SysDeliverabilityCheck),
|
||||||
|
688 => Some(Permission::SysScheduledReportGet),
|
||||||
|
689 => Some(Permission::SysScheduledReportUpdate),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
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 {
|
impl serde::Serialize for Permission {
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,7 @@ pub mod broadcast;
|
|||||||
pub mod inbuxa_lock_expiry;
|
pub mod inbuxa_lock_expiry;
|
||||||
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
||||||
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
|
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 state_manager;
|
||||||
pub mod task_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 spec, DL-14: each node checks itself daily
|
||||||
inbuxa_deliverability::spawn_deliverability(inner.clone());
|
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
|
||||||
spawn_task_scheduler(inner);
|
spawn_task_scheduler(inner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ pub mod dkim;
|
|||||||
pub mod dmarc;
|
pub mod dmarc;
|
||||||
pub mod inbound;
|
pub mod inbound;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
|
pub mod inbuxa_send; // inbuxa: signed server-built mail (scheduled-reports spec, RP-14)
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
pub mod shared; // inbuxa: reports written by every node
|
pub mod shared; // inbuxa: reports written by every node
|
||||||
|
|||||||
@@ -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: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions.
|
||||||
|
|
||||||
|
Not a rebuild: **scheduled reports and the weekly digest** are INBUXA's own design (inbuxa-drafts `specs/scheduled-reports.md`). An administrator picks sections, a schedule in a time zone and recipients on this server; every node looks for due reports once a minute, one claims each run with the task lock, and the report is built from data the server already keeps and mailed DKIM-signed: `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings` (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default.
|
||||||
|
|
||||||
## 5. The web front ends
|
## 5. The web front ends
|
||||||
|
|
||||||
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
||||||
|
|||||||
@@ -178,6 +178,26 @@ default = "none"
|
|||||||
file = "inbuxa_deliverability_settings.rs"
|
file = "inbuxa_deliverability_settings.rs"
|
||||||
default = "none"
|
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: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"]
|
[object."inbuxa:LegalHold"]
|
||||||
file = "inbuxa_legal_hold.rs"
|
file = "inbuxa_legal_hold.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
@@ -298,6 +318,18 @@ captures = ["x:DataRetention.expungeTrashAfter", "x:DataRetention.expungeSubmiss
|
|||||||
leaves_host = false
|
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"]
|
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"]
|
[source."full-text-index"]
|
||||||
categories = ["content", "identifier", "contact", "metadata"]
|
categories = ["content", "identifier", "contact", "metadata"]
|
||||||
whose = ["holder", "correspondent"]
|
whose = ["holder", "correspondent"]
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U
|
u-6dUvjU7hT6J8izT4-NlIPmmmcdzFOSwixb72qJWRg
|
||||||
@@ -18,6 +18,7 @@ pub mod compliance; // inbuxa: the compliance roles
|
|||||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
||||||
pub mod security_acceptances; // inbuxa: accepted security to-do items
|
pub mod security_acceptances; // inbuxa: accepted security to-do items
|
||||||
pub mod deliverability; // inbuxa: the deliverability check
|
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 journal; // inbuxa: journaling
|
||||||
pub mod audit; // inbuxa: the audit log
|
pub mod audit; // inbuxa: the audit log
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/*
|
||||||
|
* 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}");
|
||||||
|
|
||||||
|
// --- 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user