diff --git a/Cargo.lock b/Cargo.lock index 9683c4d..e1ea5e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3959,6 +3959,8 @@ dependencies = [ "ahash", "aho-corasick", "base64 0.23.1", + "chrono", + "chrono-tz", "flate2", "jmap_proto", "mail-builder 1.0.0", diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index c4160d4..7b7b0da 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -317,6 +317,12 @@ impl Default for DefaultPermissions { Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => { default.superuser.push(permission); } + // inbuxa: scheduled-reports spec, RP-22: a tenant administrator + // makes reports for their own tenant, which the server limits + Permission::SysScheduledReportGet | Permission::SysScheduledReportUpdate => { + default.superuser.push(permission); + default.tenant.push(permission); + } // inbuxa: DLP and mail flow rules, and held mail, are the // server's: never a tenant's (dlp-and-mail-flow-rules spec, // settled answer 3) diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 8fa2cbb..cc0bd20 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -32,8 +32,8 @@ use types::id::Id; /// (ai-explain spec, EX-4: superuser by default), the audit log, account /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and /// the data inventory (personal-data catalog spec), accepting security -/// to-do items (security to-do list spec), and the deliverability check -/// (deliverability spec). +/// to-do items (security to-do list spec), the deliverability check +/// (deliverability spec), and scheduled reports (scheduled-reports spec). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -60,6 +60,8 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysDeliverabilityGet, Permission::SysDeliverabilityUpdate, Permission::SysDeliverabilityCheck, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportUpdate, ]; /// Granted to the server-level Compliance Officer role once it exists: @@ -77,8 +79,8 @@ const OFFICER_GRANTS: &[Permission] = &[ /// Granted to the default tenant administrator roles: reading and exporting /// the tenant's audit log (AU-9), locking and delegating its accounts -/// (AL-12), the tenant's slice of the data inventory, and its own domains' -/// deliverability findings (DL-20). +/// (AL-12), the tenant's slice of the data inventory, its own domains' +/// deliverability findings (DL-20), and its own scheduled reports (RP-22). const TENANT_GRANTS: &[Permission] = &[ Permission::SysAuditGet, Permission::SysAuditExport, @@ -88,6 +90,8 @@ const TENANT_GRANTS: &[Permission] = &[ Permission::SysAccountLockDestroy, Permission::SysComplianceGet, Permission::SysDeliverabilityGet, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportUpdate, ]; #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index 4051c50..4ff56a8 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -28,6 +28,9 @@ zip = "8.6" quick-xml = "0.41" mail-parser = { version = "0.11", features = ["full_encoding"] } mail-builder = { version = "1.0" } +# inbuxa: scheduled reports run at a local time (scheduled-reports spec, RP-15) +chrono = { version = "0.4", default-features = false, features = ["std"] } +chrono-tz = "0.10" [dev-dependencies] tokio = { version = "1.53", features = ["macros", "rt"] } diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 915dc34..da86f41 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -28,6 +28,7 @@ pub mod lock; pub mod mailflow; pub mod masked_email; pub mod privacy; +pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest (not a rebuild) pub mod security; pub mod tenancy; pub mod undelete; diff --git a/crates/features/src/scheduled_reports/mod.rs b/crates/features/src/scheduled_reports/mod.rs new file mode 100644 index 0000000..d2a0b2b --- /dev/null +++ b/crates/features/src/scheduled_reports/mod.rs @@ -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 { + 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, + 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
, + 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, + pub attach_csv: bool, + /// RP-22: a tenant's report, limited as RP-12 says. + pub tenant_id: Option, + 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, + /// RP-5: what was failing at the last run, to say what changed. + pub failing: Vec, + /// 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
{ + 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::().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 { + 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 { + 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, + /// Empty means postmaster at the server's default domain. + pub from_address: Option, +} + +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); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize a scheduled report") + .reason(err) + }) + } +} + +impl SerdeDeserialize<'de> + Send + Sync> Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + 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) -> 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> { + Ok(data + .get_value::>(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> { + 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::::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 { + Ok(data + .get_value::>(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!["ops@example.org".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!["ops@example.org".into(); 51]; + assert!(r.validate().is_err()); + r.recipients = vec!["ops@example.org".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); + } +} diff --git a/crates/jmap-proto/src/object/inbuxa_report_export.rs b/crates/jmap-proto/src/object/inbuxa_report_export.rs new file mode 100644 index 0000000..6174ce4 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_report_export.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ReportExport/set` under `urn:inbuxa:jmap`: a scheduled report +//! for a period as a ZIP of a summary and CSVs, without mailing anyone +//! (scheduled-reports spec, RP-19). Exports aren't kept; `/get` finds none. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct ReportExport; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReportExportProperty { + Id, + ReportId, + From, + To, + BlobId, + Size, + Sha256, + Files, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReportExportValue { + Id(Id), +} + +impl Property for ReportExportProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the lists stay plain keys + match parent { + None => ReportExportProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ReportExportProperty::Id => "id", + ReportExportProperty::ReportId => "reportId", + ReportExportProperty::From => "from", + ReportExportProperty::To => "to", + ReportExportProperty::BlobId => "blobId", + ReportExportProperty::Size => "size", + ReportExportProperty::Sha256 => "sha256", + ReportExportProperty::Files => "files", + } + .into() + } +} + +impl ReportExportProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => ReportExportProperty::Id, + b"reportId" => ReportExportProperty::ReportId, + b"from" => ReportExportProperty::From, + b"to" => ReportExportProperty::To, + b"blobId" => ReportExportProperty::BlobId, + b"size" => ReportExportProperty::Size, + b"sha256" => ReportExportProperty::Sha256, + b"files" => ReportExportProperty::Files, + ) + } +} + +impl FromStr for ReportExportProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + ReportExportProperty::parse(s).ok_or(()) + } +} + +impl Element for ReportExportValue { + type Property = ReportExportProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(ReportExportProperty::Id) => { + Id::from_str(value).ok().map(ReportExportValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + ReportExportValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for ReportExport { + type Property = ReportExportProperty; + + type Element = ReportExportValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = ReportExportProperty::Id; +} + +impl From for ReportExportValue { + fn from(id: Id) -> Self { + ReportExportValue::Id(id) + } +} + +impl JmapObjectId for ReportExportValue { + fn as_id(&self) -> Option { + match self { + ReportExportValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + ReportExportValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = ReportExportValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for ReportExportProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/inbuxa_scheduled_report.rs b/crates/jmap-proto/src/object/inbuxa_scheduled_report.rs new file mode 100644 index 0000000..d8d6596 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_scheduled_report.rs @@ -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 { + // 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 { + 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 { + ScheduledReportProperty::parse(s).ok_or(()) + } +} + +impl Element for ScheduledReportValue { + type Property = ScheduledReportProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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 for ScheduledReportValue { + fn from(id: Id) -> Self { + ScheduledReportValue::Id(id) + } +} + +impl JmapObjectId for ScheduledReportValue { + fn as_id(&self) -> Option { + match self { + ScheduledReportValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/inbuxa_scheduled_report_settings.rs b/crates/jmap-proto/src/object/inbuxa_scheduled_report_settings.rs new file mode 100644 index 0000000..8de011e --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_scheduled_report_settings.rs @@ -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 { + // 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 { + 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 { + ScheduledReportSettingsProperty::parse(s).ok_or(()) + } +} + +impl Element for ScheduledReportSettingsValue { + type Property = ScheduledReportSettingsProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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 for ScheduledReportSettingsValue { + fn from(id: Id) -> Self { + ScheduledReportSettingsValue::Id(id) + } +} + +impl JmapObjectId for ScheduledReportSettingsValue { + fn as_id(&self) -> Option { + match self { + ScheduledReportSettingsValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index b644b1e..79ed5e7 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -33,6 +33,9 @@ pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check +pub mod inbuxa_report_export; // inbuxa: scheduled reports, RP-19 +pub mod inbuxa_scheduled_report; // inbuxa: scheduled reports +pub mod inbuxa_scheduled_report_settings; // inbuxa: scheduled reports pub mod inbuxa_journal; // inbuxa: journaling pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export pub mod inbuxa_held_message; // inbuxa: mail held for review diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 585dc51..f265811 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -97,6 +97,15 @@ impl Response<'_> { GetResponseMethod::DeliverabilitySettings(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::ReportExport(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::ScheduledReport(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::ScheduledReportSettings(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Journal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index 87bf1b3..1c3f56d 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -58,6 +58,9 @@ impl Response<'_> { GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?, + GetRequestMethod::ReportExport(request) => request.resolve_references(self)?, + GetRequestMethod::ScheduledReport(request) => request.resolve_references(self)?, + GetRequestMethod::ScheduledReportSettings(request) => request.resolve_references(self)?, GetRequestMethod::Journal(request) => request.resolve_references(self)?, GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, @@ -148,6 +151,15 @@ impl Response<'_> { SetRequestMethod::DeliverabilitySettings(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::ReportExport(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::ScheduledReport(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::ScheduledReportSettings(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::Journal(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 07dfd2e..9020fcc 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -73,6 +73,9 @@ pub enum MethodObject { // inbuxa: the deliverability check DeliverabilityReport, DeliverabilitySettings, + ReportExport, + ScheduledReport, + ScheduledReportSettings, HeldMessage, // inbuxa: journaling Journal, @@ -124,6 +127,9 @@ impl MethodObject { | MethodObject::HeldMessage | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport + | MethodObject::ScheduledReport + | MethodObject::ScheduledReportSettings | MethodObject::Journal | MethodObject::JournalEntry | MethodObject::JournalExport @@ -332,6 +338,12 @@ impl MethodName { (MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set", (MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get", (MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set", + (MethodFunction::Get, MethodObject::ReportExport) => "inbuxa:ReportExport/get", + (MethodFunction::Set, MethodObject::ReportExport) => "inbuxa:ReportExport/set", + (MethodFunction::Get, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/get", + (MethodFunction::Set, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/set", + (MethodFunction::Get, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/get", + (MethodFunction::Set, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/set", (MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get", (MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set", (MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get", @@ -510,6 +522,12 @@ impl MethodName { "inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set), "inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get), "inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set), + "inbuxa:ReportExport/get" => (MethodObject::ReportExport, MethodFunction::Get), + "inbuxa:ReportExport/set" => (MethodObject::ReportExport, MethodFunction::Set), + "inbuxa:ScheduledReport/get" => (MethodObject::ScheduledReport, MethodFunction::Get), + "inbuxa:ScheduledReport/set" => (MethodObject::ScheduledReport, MethodFunction::Set), + "inbuxa:ScheduledReportSettings/get" => (MethodObject::ScheduledReportSettings, MethodFunction::Get), + "inbuxa:ScheduledReportSettings/set" => (MethodObject::ScheduledReportSettings, MethodFunction::Set), "inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get), "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get), @@ -595,6 +613,9 @@ impl Display for MethodObject { MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport", MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings", + MethodObject::ReportExport => "inbuxa:ReportExport", + MethodObject::ScheduledReport => "inbuxa:ScheduledReport", + MethodObject::ScheduledReportSettings => "inbuxa:ScheduledReportSettings", MethodObject::Journal => "inbuxa:Journal", MethodObject::JournalEntry => "inbuxa:JournalEntry", MethodObject::JournalExport => "inbuxa:JournalExport", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index bdfd46a..5de1895 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -128,6 +128,9 @@ pub enum GetRequestMethod { SecurityAcceptance(Box>), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), + ScheduledReport(Box>), + ScheduledReportSettings(Box>), Journal(Box>), JournalEntry(Box>), HeldMessage(Box>), @@ -176,6 +179,9 @@ pub enum SetRequestMethod<'x> { ), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), + ScheduledReport(Box>), + ScheduledReportSettings(Box>), Journal(Box>), JournalExport(Box>), JournalVerification(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index aa12645..1ce359b 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -715,6 +715,48 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::ScheduledReport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ScheduledReport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::ScheduledReportSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReportSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ScheduledReportSettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReportSettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::ReportExport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ReportExport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::ReportExport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ReportExport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: journaling (MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 971f7c8..d0272b3 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -115,6 +115,9 @@ pub enum GetResponseMethod { SecurityAcceptance(GetResponse), DeliverabilityReport(GetResponse), DeliverabilitySettings(GetResponse), + ReportExport(GetResponse), + ScheduledReport(GetResponse), + ScheduledReportSettings(GetResponse), Journal(GetResponse), JournalEntry(GetResponse), HeldMessage(GetResponse), @@ -163,6 +166,9 @@ pub enum SetResponseMethod { ), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ReportExport(Box>), + ScheduledReport(Box>), + ScheduledReportSettings(Box>), Journal(Box>), JournalExport(Box>), JournalVerification(Box>), @@ -892,6 +898,39 @@ impl<'x> From From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ReportExport(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ReportExport(Box::new(value))) + } +} +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ScheduledReport(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ScheduledReport(Box::new(value))) + } +} +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::ScheduledReportSettings(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::ScheduledReportSettings(Box::new(value))) + } +} // inbuxa: accepted security to-do items impl<'x> From> diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 3c4f2c4..2ba40cb 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -127,6 +127,11 @@ impl JmapAuthorization for AccessToken { // page that shows the findings, so they read the same way GetRequestMethod::DeliverabilityReport(_) | GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet, + // inbuxa: scheduled-reports spec; a tenant administrator sees + // their own tenant's reports (RP-22) + GetRequestMethod::ScheduledReport(_) + | GetRequestMethod::ScheduledReportSettings(_) + | GetRequestMethod::ReportExport(_) => Permission::SysScheduledReportGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -356,6 +361,29 @@ impl JmapAuthorization for AccessToken { Permission::SysDeliverabilityUpdate, Permission::SysDeliverabilityUpdate, ), + // inbuxa: scheduled reports; Send now is an update (RP-18) + SetRequestMethod::ScheduledReport(s) => validate_set( + s, + self, + Permission::SysScheduledReportUpdate, + Permission::SysScheduledReportUpdate, + Permission::SysScheduledReportUpdate, + ), + SetRequestMethod::ScheduledReportSettings(s) => validate_set( + s, + self, + Permission::SysScheduledReportUpdate, + Permission::SysScheduledReportUpdate, + Permission::SysScheduledReportUpdate, + ), + // inbuxa: RP-19, a download reads what the report would send + SetRequestMethod::ReportExport(s) => validate_set( + s, + self, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportGet, + Permission::SysScheduledReportGet, + ), // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -529,6 +557,9 @@ impl JmapAuthorization for AccessToken { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport + | MethodObject::ScheduledReport + | MethodObject::ScheduledReportSettings | MethodObject::HeldMessage | MethodObject::Journal | MethodObject::JournalEntry diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 351258c..2cd4895 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -299,6 +299,15 @@ impl RequestHandler for Server { SetResponseMethod::DeliverabilitySettings(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::ReportExport(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::ScheduledReport(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::ScheduledReportSettings(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::Journal(set_response) => { set_response.update_created_ids(&mut response); } @@ -558,6 +567,25 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: scheduled reports + GetRequestMethod::ScheduledReport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::scheduled_reports::get_reports(self, access_token, *req) + .await? + .into() + } + GetRequestMethod::ScheduledReportSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::scheduled_reports::get_settings(self, access_token, *req) + .await? + .into() + } + GetRequestMethod::ReportExport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::scheduled_reports::get_exports(self, access_token, *req) + .await? + .into() + } // inbuxa: journaling GetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -1086,6 +1114,71 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: scheduled reports; every change is in the audit log + SetRequestMethod::ScheduledReport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::scheduled_reports::set_reports( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } + // inbuxa: RP-19; a download is in the audit log too + SetRequestMethod::ReportExport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::scheduled_reports::set_exports( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } + SetRequestMethod::ScheduledReportSettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::scheduled_reports::set_settings( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } // inbuxa: DL-6; which lists are asked is in the audit log SetRequestMethod::DeliverabilitySettings(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index f52ff79..398d786 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -434,6 +434,9 @@ impl IntermediateChangesResponse { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ReportExport + | MethodObject::ScheduledReport + | MethodObject::ScheduledReportSettings | MethodObject::Journal | MethodObject::JournalEntry | MethodObject::JournalExport diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index cd0e899..d028b96 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -13,6 +13,7 @@ pub mod legal_hold; pub mod mail_rule; pub mod security_acceptance; pub mod deliverability; // inbuxa: the deliverability check +pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod journal; pub mod journal_entry; pub mod held_message; diff --git a/crates/jmap/src/inbuxa/scheduled_reports.rs b/crates/jmap/src/inbuxa/scheduled_reports.rs new file mode 100644 index 0000000..5edc603 --- /dev/null +++ b/crates/jmap/src/inbuxa/scheduled_reports.rs @@ -0,0 +1,690 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings` +//! (scheduled-reports spec). +//! +//! Reading needs `sysScheduledReportGet`, changing (and Send now, RP-18) +//! `sysScheduledReportUpdate`. A tenant administrator sees and changes only +//! their own tenant's reports, and a report they create is their tenant's +//! (RP-22). The weekly digest can be changed or turned off, not deleted, and +//! its recipients are the system administrators (RP-21). Recipients must be +//! accounts on this server (RP-23). + +use common::{Server, auth::AccessToken}; +use inbuxa_features::scheduled_reports::{self as model, Report, RunStatus, Schedule, Section}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::{ + inbuxa_report_export::{ReportExport, ReportExportProperty as X, ReportExportValue}, + inbuxa_scheduled_report::{ + ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue, + }, + inbuxa_scheduled_report_settings::{ + ScheduledReportSettings, ScheduledReportSettingsProperty as S, + ScheduledReportSettingsValue, + }, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Map, Property, Value}; +use std::{borrow::Cow, str::FromStr}; +use store::write::now; +use types::id::Id; + +const REPORT: &[R] = &[ + R::Id, + R::Name, + R::Enabled, + R::BuiltIn, + R::Sections, + R::Schedule, + R::Recipients, + R::AttachCsv, + R::MemberTenantId, + R::CreatedAt, + R::NextRunAt, + R::Runs, +]; + +const SETTINGS: &[S] = &[S::Id, S::FromName, S::FromAddress]; + +fn json_to_value(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(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, +) -> trc::Result> { + 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 = 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> { + 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::>>() + }); + 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::(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::>>() + }); + 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> { + 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> { + 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, +) -> trc::Result> { + 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> { + if access_token.tenant_id().is_some() { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("Who reports come from is the server's.")); + } + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response + .not_created + .append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + let data = server.store(); + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + let mut settings = model::settings(data).await?; + let mut error = None; + for (key, value) in value.into_expanded_object() { + let json = serde_json::to_value(&value).unwrap_or_default(); + match &key { + Key::Property(S::FromName) => { + settings.from_name = json + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } + Key::Property(S::FromAddress) => { + match json.as_str().map(|s| s.trim().to_lowercase()) { + Some(a) if a.is_empty() => settings.from_address = None, + Some(a) if a.contains('@') && !a.ends_with('@') => { + settings.from_address = Some(a) + } + _ => { + error = Some( + SetError::invalid_properties() + .with_property(S::FromAddress) + .with_description("An email address."), + ); + break; + } + } + } + Key::Property(property) => { + error = Some( + SetError::invalid_properties() + .with_property(property.clone()) + .with_description("The server sets this."), + ); + break; + } + _ => { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + } + } + } + match error { + Some(error) => response.not_updated.append(id, error), + None => { + model::put_settings(data, &settings).await?; + response.updated.append(id, None); + } + } + } + Ok(response) +} + +/// RP-19: the furthest back a download goes, as far as metrics are kept. +const EXPORT_MAX_AGE: u64 = 90 * 86_400; + +fn parse_date(value: &serde_json::Value) -> Option { + value + .as_str() + .and_then(|s| UTCDate::from_str(s).ok()) + .map(|d| d.timestamp().max(0) as u64) +} + +/// `inbuxa:ReportExport/get`: exports aren't kept, so there's never one. +pub async fn get_exports( + _server: &Server, + _access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + for id in ids.unwrap_or_default() { + response.push_not_found(id); + } + Ok(response) +} + +/// `inbuxa:ReportExport/set`: create `{reportId, from?, to?}` builds that +/// report for the period (by default its last full one) as a ZIP of a +/// summary and CSVs, stored as an upload, and mails nobody (RP-19). +pub async fn set_exports( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, ReportExport>, +) -> trc::Result> { + use sha2::{Digest, Sha256}; + use std::io::{Cursor, Write}; + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; + + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Exports can't be changed."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("Exports aren't kept to destroy."), + ); + } + for (client_id, value) in request.unwrap_create() { + let json = serde_json::to_value(&value).unwrap_or_default(); + let invalid = |property: X, why: &str| { + SetError::invalid_properties() + .with_property(property) + .with_description(why.to_string()) + }; + let report = match json + .get("reportId") + .and_then(|v| v.as_str()) + .and_then(|s| Id::from_str(s).ok()) + { + Some(id) => model::report(server.store(), id.id()) + .await? + .filter(|r| visible(access_token, r)), + None => None, + }; + let Some(report) = report else { + response + .not_created + .append(client_id, invalid(X::ReportId, "A report you can see.")); + continue; + }; + let now = now(); + let (default_from, default_to) = report.schedule.period( + report + .schedule + .next_due(report.last_due.min(now)) + .filter(|d| *d <= now) + .unwrap_or(report.last_due.min(now)), + ); + let from = json + .get("from") + .and_then(parse_date) + .unwrap_or(default_from); + let to = json.get("to").and_then(parse_date).unwrap_or(default_to); + if from >= to || to > now + 60 || from + EXPORT_MAX_AGE < now { + response.not_created.append( + client_id, + invalid( + X::From, + "A period that has started, ends no later than now, and goes back at most 90 days.", + ), + ); + continue; + } + let files = + services::inbuxa_scheduled_reports::export_files(server, &report, from, to).await?; + let fail = |err: zip::result::ZipError| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to write a report export") + .reason(err) + }; + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + let mut zip = ZipWriter::new(Cursor::new(Vec::new())); + let names: Vec = files.iter().map(|(name, _)| name.clone()).collect(); + for (name, body) in &files { + zip.start_file(name.as_str(), options).map_err(fail)?; + zip.write_all(body).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to write a report export") + .reason(err) + })?; + } + let bytes = zip.finish().map_err(fail)?.into_inner(); + let sha256 = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let blob = server + .put_jmap_blob(access_token.account_id(), &bytes) + .await?; + + let mut created = Map::with_capacity(7); + created.insert_unchecked( + Key::Property(X::Id), + Value::Element(ReportExportValue::Id(Id::from(now))), + ); + created.insert_unchecked( + Key::Property(X::BlobId), + Value::Str(blob.to_string().into()), + ); + created.insert_unchecked( + Key::Property(X::Size), + Value::Number((bytes.len() as u64).into()), + ); + created.insert_unchecked(Key::Property(X::Sha256), Value::Str(sha256.into())); + created.insert_unchecked(Key::Property(X::From), date(from)); + created.insert_unchecked(Key::Property(X::To), date(to)); + created.insert_unchecked( + Key::Property(X::Files), + Value::Array(names.into_iter().map(|n| Value::Str(n.into())).collect()), + ); + response.created.insert(client_id, Value::Object(created)); + } + Ok(response) +} diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index ae05237..758a586 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1766,6 +1766,9 @@ pub enum Permission { SysDeliverabilityGet = 685, SysDeliverabilityUpdate = 686, SysDeliverabilityCheck = 687, + // inbuxa: scheduled reports and the weekly digest + SysScheduledReportGet = 688, + SysScheduledReportUpdate = 689, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 87edc65..1190469 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7105,6 +7105,8 @@ impl EnumImpl for Permission { b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet, b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate, b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck, + b"sysScheduledReportGet" => Permission::SysScheduledReportGet, + b"sysScheduledReportUpdate" => Permission::SysScheduledReportUpdate, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7809,6 +7811,8 @@ impl EnumImpl for Permission { Permission::SysDeliverabilityGet => "sysDeliverabilityGet", Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate", Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck", + Permission::SysScheduledReportGet => "sysScheduledReportGet", + Permission::SysScheduledReportUpdate => "sysScheduledReportUpdate", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8506,6 +8510,8 @@ impl EnumImpl for Permission { 685 => Some(Permission::SysDeliverabilityGet), 686 => Some(Permission::SysDeliverabilityUpdate), 687 => Some(Permission::SysDeliverabilityCheck), + 688 => Some(Permission::SysScheduledReportGet), + 689 => Some(Permission::SysScheduledReportUpdate), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8950,7 +8956,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 688; + const COUNT: usize = 690; } impl serde::Serialize for Permission { diff --git a/crates/services/src/inbuxa_scheduled_reports.rs b/crates/services/src/inbuxa_scheduled_reports.rs new file mode 100644 index 0000000..fd4afb1 --- /dev/null +++ b/crates/services/src/inbuxa_scheduled_reports.rs @@ -0,0 +1,1310 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Scheduled reports and the weekly digest (scheduled-reports spec). +//! +//! Every node looks once a minute for reports that are due. A run is +//! claimed with the task lock, keyed by report and due time, and the due +//! time is recorded on the report, so it goes once however many nodes there +//! are (RP-16). The report is built from what the server already keeps +//! (RP-1 to RP-8) and mailed signed (RP-14) to accounts on this server +//! (RP-23). + +use common::{BuildServer, Inner, KV_LOCK_TASK, Server}; +use inbuxa_features::{ + deliverability::{self as dlv, DkimState, ListingState}, + scheduled_reports::{self as model, Report, Run, RunStatus, Section}, +}; +use mail_builder::{ + MessageBuilder, + headers::{HeaderType, address::Address}, +}; +use registry::{ + schema::{ + enums::{DkimAuthResult, DmarcActionDisposition, DmarcResult, StorageQuota, TlsResultType}, + prelude::{Object, ObjectInner, ObjectType, Property}, + structs::{Account, Certificate, Domain, UserRoles}, + }, + types::EnumImpl, +}; +use smtp::reporting::inbuxa_send::send_signed; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + sync::Arc, + time::Duration, +}; +use store::{ + ValueKey, + registry::RegistryQuery, + write::{RegistryClass, ValueClass, now}, +}; +use trc::MetricType; +use types::id::Id; +use utils::snowflake::SnowflakeIdGenerator; + +const TICK: Duration = Duration::from_secs(60); +const SETTLE: Duration = Duration::from_secs(90); +/// RP-7. +const QUOTA_WARN: f64 = 0.9; +/// RP-8. +const CERT_DAYS: u64 = 21; +/// RP-17: failed runs in a row before the dashboard hears of it. +pub const FAILURES_FOR_ATTENTION: u32 = 2; + +pub fn spawn_scheduled_reports(inner: Arc) { + tokio::spawn(async move { + tokio::time::sleep(SETTLE).await; + loop { + let server = inner.build_server(); + if let Err(err) = tick(&server).await { + trc::error!(err.details("Failed to run scheduled reports")); + } + tokio::time::sleep(TICK).await; + } + }); +} + +async fn tick(server: &Server) -> trc::Result<()> { + let now = now(); + model::ensure_digest(server.store(), now).await?; + for report in model::reports(server.store()).await? { + if !report.enabled { + continue; + } + let Some(due) = report.schedule.next_due(report.last_due) else { + continue; + }; + if due > now { + continue; + } + let key = [ + b"sched-report:".as_slice(), + &report.id.to_be_bytes(), + &due.to_be_bytes(), + ] + .concat(); + match server + .in_memory_store() + .try_lock(KV_LOCK_TASK, &key, 3600) + .await + { + Ok(true) => {} + Ok(false) => continue, + Err(err) => { + trc::error!(err.details("Failed to claim a scheduled report run")); + continue; + } + } + // A server that was down sends one catch-up run, not one per miss + let (from, to) = report.schedule.period(due.max(now - 60)); + let outcome = run(server, &report, from, to, false).await; + record(server, report.id, outcome, Some(now.max(due))).await?; + } + Ok(()) +} + +/// RP-18: builds the current period and mails it now. +pub fn send_now(server: Server, id: u64) { + tokio::spawn(async move { + let result = async { + let Some(report) = model::report(server.store(), id).await? else { + return Ok(()); + }; + let (from, to) = report.schedule.period(now()); + let outcome = run(&server, &report, from, to, true).await; + record(&server, id, outcome, None).await + } + .await; + if let Err(err) = result { + trc::error!(err.details("Failed to send a report by hand")); + } + }); +} + +struct Outcome { + run: Run, + failing: Option>, +} + +/// Writes the run onto the report as it is now, so an edit made meanwhile +/// isn't lost. +async fn record(server: &Server, id: u64, outcome: Outcome, due: Option) -> trc::Result<()> { + let Some(mut report) = model::report(server.store(), id).await? else { + return Ok(()); + }; + if let Some(due) = due { + report.last_due = due; + if outcome.run.status == RunStatus::Failed { + report.failed_in_a_row += 1; + } else { + report.failed_in_a_row = 0; + } + } + if let Some(failing) = outcome.failing { + report.failing = failing; + } + report.push_run(outcome.run); + model::put_report(server.store(), &report).await +} + +async fn run(server: &Server, report: &Report, from: u64, to: u64, by_hand: bool) -> Outcome { + let started = now(); + let failed = |reason: String| Outcome { + run: Run { + at: started, + by_hand, + status: RunStatus::Failed, + reason: Some(reason), + ..Run::default() + }, + failing: None, + }; + + let recipients = match recipients(server, report).await { + Ok(r) if r.is_empty() => { + return failed("No recipient is an account on this server.".into()); + } + Ok(r) => r, + Err(err) => { + trc::error!(err.details("Failed to resolve report recipients")); + return failed("The recipients couldn't be looked up.".into()); + } + }; + let built = match build(server, report, from, to).await { + Ok(built) => built, + Err(err) => { + trc::error!(err.details("Failed to build a scheduled report")); + return failed("The report couldn't be built.".into()); + } + }; + let settings = model::settings(server.store()).await.unwrap_or_default(); + let from_address = settings + .from_address + .clone() + .filter(|a| a.contains('@')) + .unwrap_or_else(|| format!("postmaster@{}", server.core.email.default_domain_name)); + let sign_domain = from_address + .rsplit('@') + .next() + .unwrap_or_default() + .to_string(); + let message = compose( + report, + &built, + from, + to, + settings.from_name(), + &from_address, + &recipients, + ); + let size = message.len() as u64; + match send_signed(server, &from_address, &recipients, &message, &sign_domain).await { + Ok(()) => Outcome { + run: Run { + at: started, + by_hand, + status: RunStatus::Sent, + reason: None, + recipients: recipients.len() as u32, + size, + }, + failing: built.failing, + }, + Err(reason) => failed(reason), + } +} + +/// RP-21: the system administrators; RP-23: otherwise the report's own +/// recipients that are accounts on this server. +async fn recipients(server: &Server, report: &Report) -> trc::Result> { + if report.built_in { + let mut out = Vec::new(); + let mut domains = DomainNames::default(); + for id in server + .registry() + .query::>(RegistryQuery::new(ObjectType::Account)) + .await? + { + if let Some(Account::User(user)) = server.registry().object::(id).await? + && matches!(user.roles, UserRoles::Admin) + && user.member_tenant_id.is_none() + && let Some(domain) = domains.get(server, user.domain_id).await? + { + out.push(format!("{}@{}", user.name, domain)); + } + } + return Ok(out); + } + let mut out = Vec::new(); + for address in &report.recipients { + if server.rcpt_id_from_email(address).await?.is_some() { + out.push(address.to_lowercase()); + } + } + out.dedup(); + Ok(out) +} + +#[derive(Default)] +struct DomainNames(HashMap>); + +impl DomainNames { + async fn get(&mut self, server: &Server, id: Id) -> trc::Result> { + if let Some(name) = self.0.get(&id) { + return Ok(name.clone()); + } + let name = server + .registry() + .object::(id) + .await? + .map(|d| d.name.to_lowercase()); + self.0.insert(id, name.clone()); + Ok(name) + } +} + +// --- Building ------------------------------------------------------------- + +struct Part { + title: &'static str, + lines: Vec, + csv: Option<(String, String)>, + link: &'static str, +} + +struct Built { + attention: Vec, + parts: Vec, + /// RP-5: what's failing now, to keep for next time. + failing: Option>, +} + +async fn build(server: &Server, report: &Report, from: u64, to: u64) -> trc::Result { + let mut built = Built { + attention: Vec::new(), + parts: Vec::new(), + failing: None, + }; + let tenant = report.tenant_id; + for section in report.effective_sections() { + let part = match section { + Section::MailFlow => mail_flow(server, from, to).await?, + Section::Queue => queue(server, from, to).await?, + Section::Spoofing => spoofing(server, tenant, from, to, &mut built.attention).await?, + Section::TlsFailures => { + tls_failures(server, tenant, from, to, &mut built.attention).await? + } + Section::Deliverability => { + let (part, failing) = + deliverability(server, tenant, &report.failing, &mut built.attention).await?; + built.failing = Some(failing); + part + } + Section::Security => security(server, from, to).await?, + Section::Storage => storage(server, tenant, from, to, &mut built.attention).await?, + Section::Certificates => certificates(server, to, &mut built.attention).await?, + }; + if let Some(part) = part { + built.parts.push(part); + } + } + built.attention.truncate(5); + Ok(built) +} + +fn number(n: u64) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + +fn change(now: u64, before: u64) -> String { + if before == 0 { + String::new() + } else { + let pct = (now as f64 - before as f64) / before as f64 * 100.0; + if pct.abs() < 1.0 { + " (about the same as before)".into() + } else if pct > 0.0 { + format!(" (up {:.0}%)", pct) + } else { + format!(" (down {:.0}%)", -pct) + } + } +} + +fn plural(n: u64, one: &str, many: &str) -> String { + format!("{} {}", number(n), if n == 1 { one } else { many }) +} + +fn csv_field(value: &str) -> String { + if value.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn csv(header: &[&str], rows: &[Vec]) -> String { + let mut out = header.join(","); + out.push_str("\r\n"); + for row in rows { + out.push_str( + &row.iter() + .map(|v| csv_field(v)) + .collect::>() + .join(","), + ); + out.push_str("\r\n"); + } + out +} + +/// Counter totals for each metric over [from, to), all nodes. +async fn counters(server: &Server, from: u64, to: u64, names: &[&str]) -> trc::Result> { + let wanted: Vec> = names.iter().map(|n| MetricType::parse(n)).collect(); + let mut totals = vec![0u64; names.len()]; + let (Some(from_id), Some(to_id)) = ( + SnowflakeIdGenerator::from_timestamp(from), + SnowflakeIdGenerator::from_timestamp(to), + ) else { + return Ok(totals); + }; + for sample in server.read_metrics(from_id, to_id, true, |_| true).await? { + if let registry::schema::structs::Metric::Counter(c) = &sample.metric + && let Some(i) = wanted.iter().position(|w| *w == Some(c.metric)) + { + totals[i] += c.count; + } + } + Ok(totals) +} + +/// Each node's histogram totals only grow (until a restart), so the period's +/// count and sum are the positive steps between its samples. +async fn histogram(server: &Server, from: u64, to: u64, name: &str) -> trc::Result<(u64, u64)> { + let Some(wanted) = MetricType::parse(name) else { + return Ok((0, 0)); + }; + let (Some(from_id), Some(to_id)) = ( + SnowflakeIdGenerator::from_timestamp(from), + SnowflakeIdGenerator::from_timestamp(to), + ) else { + return Ok((0, 0)); + }; + let mut last: HashMap = HashMap::new(); + let (mut count, mut sum) = (0, 0); + for sample in server.read_metrics(from_id, to_id, true, |_| true).await? { + if let registry::schema::structs::Metric::Histogram(h) = &sample.metric + && h.metric == wanted + { + let node = SnowflakeIdGenerator::to_node_id(sample.id); + if let Some((c, s)) = last.get(&node) + && h.count >= *c + && h.sum >= *s + { + count += h.count - c; + sum += h.sum - s; + } + last.insert(node, (h.count, h.sum)); + } + } + Ok((count, sum)) +} + +/// A gauge's first and last readings in [from, to). +async fn gauge(server: &Server, from: u64, to: u64, name: &str) -> trc::Result> { + let Some(wanted) = MetricType::parse(name) else { + return Ok(None); + }; + let (Some(from_id), Some(to_id)) = ( + SnowflakeIdGenerator::from_timestamp(from), + SnowflakeIdGenerator::from_timestamp(to), + ) else { + return Ok(None); + }; + let mut first = None; + let mut last = None; + for sample in server.read_metrics(from_id, to_id, true, |_| true).await? { + if let registry::schema::structs::Metric::Gauge(g) = &sample.metric + && g.metric == wanted + { + first.get_or_insert(g.count); + last = Some(g.count); + } + } + Ok(first.zip(last)) +} + +/// RP-1. +async fn mail_flow(server: &Server, from: u64, to: u64) -> trc::Result> { + const NAMES: [&str; 5] = [ + "queue.message-queued", + "queue.authenticated-message-queued", + "message-ingest.spam", + "queue.dsn-queued", + "message-ingest.ham", + ]; + let len = to - from; + let now = counters(server, from, to, &NAMES).await?; + let before = counters(server, from.saturating_sub(len), from, &NAMES).await?; + if now.iter().all(|n| *n == 0) && before.iter().all(|n| *n == 0) { + return Ok(None); + } + let received = now[0].saturating_sub(now[1]); + let received_before = before[0].saturating_sub(before[1]); + let mut lines = vec![ + format!( + "Received: {}{}", + plural(received, "message", "messages"), + change(received, received_before) + ), + format!( + "Sent by your people: {}{}", + plural(now[1], "message", "messages"), + change(now[1], before[1]) + ), + format!( + "Delivered as spam: {}{}", + plural(now[2], "message", "messages"), + change(now[2], before[2]) + ), + format!( + "Bounced: {}{}", + plural(now[3], "message", "messages"), + change(now[3], before[3]) + ), + ]; + let (count, sum) = histogram(server, from, to, "delivery.total-time").await?; + if count > 0 { + let avg_ms = sum / count; + lines.push(if avg_ms < 1000 { + format!("Average delivery time: {} ms", avg_ms) + } else { + format!("Average delivery time: {:.1} s", avg_ms as f64 / 1000.0) + }); + } + Ok(Some(Part { + title: "Mail flow", + lines, + csv: None, + link: "Management/CustomComponent/Dashboard", + })) +} + +/// RP-2. +async fn queue(server: &Server, from: u64, to: u64) -> trc::Result> { + let Some((_, waiting)) = gauge(server, from, to, "queue.count").await? else { + return Ok(None); + }; + if waiting == 0 { + return Ok(None); + } + Ok(Some(Part { + title: "Queue", + lines: vec![format!( + "{} waiting to be delivered at the end of the period.", + plural(waiting, "message", "messages") + )], + csv: None, + link: "Management/x:QueuedMessage", + })) +} + +/// Received reports of one kind whose arrival falls in [from, to). +async fn received( + server: &Server, + object_type: ObjectType, + tenant: Option, + from: u64, + to: u64, +) -> trc::Result> { + let ids = server + .registry() + .query::>(RegistryQuery::new(object_type).greater_than(Property::ExpiresAt, 0u64)) + .await?; + let object_id = object_type.to_id(); + let mut out = Vec::new(); + for id in ids { + let Some(object) = server + .store() + .get_value::(ValueKey::from(ValueClass::Registry(RegistryClass::Item { + object_id, + item_id: id.id(), + }))) + .await? + else { + continue; + }; + if let Some(tenant) = tenant + && object.inner.member_tenant_id() != Some(Id::from(tenant)) + { + continue; + } + let received_at = match &object.inner { + ObjectInner::DmarcExternalReport(r) => r.received_at.timestamp(), + ObjectInner::TlsExternalReport(r) => r.received_at.timestamp(), + _ => continue, + } as u64; + if received_at >= from && received_at < to { + out.push(object.inner); + } + } + Ok(out) +} + +#[derive(Default)] +struct Spoofed { + failed: u64, + delivered: u64, + quarantined: u64, + rejected: u64, + sources: BTreeMap, +} + +/// RP-3: the console's grouping (#88), here: a failure is a record whose +/// DKIM and SPF both failed DMARC. +async fn spoofing( + server: &Server, + tenant: Option, + from: u64, + to: u64, + attention: &mut Vec, +) -> trc::Result> { + let mut domains: BTreeMap = BTreeMap::new(); + for inner in received(server, ObjectType::DmarcExternalReport, tenant, from, to).await? { + let ObjectInner::DmarcExternalReport(r) = inner else { + continue; + }; + for record in r.report.records.iter() { + let passed = record.evaluated_dkim == DmarcResult::Pass + || record.evaluated_spf == DmarcResult::Pass + || record.dkim_results.iter().any(|d| { + d.result == DkimAuthResult::Pass + && d.domain.eq_ignore_ascii_case(&r.report.policy_domain) + }); + if passed { + continue; + } + let d = domains + .entry(r.report.policy_domain.to_lowercase()) + .or_default(); + d.failed += record.count; + match record.evaluated_disposition { + DmarcActionDisposition::Reject => d.rejected += record.count, + DmarcActionDisposition::Quarantine => d.quarantined += record.count, + _ => d.delivered += record.count, + } + let source = record + .source_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "unknown".into()); + *d.sources.entry(source).or_default() += record.count; + } + } + domains.retain(|_, d| d.failed > 0); + if domains.is_empty() { + return Ok(None); + } + let mut lines = Vec::new(); + let mut rows = Vec::new(); + for (domain, d) in &domains { + attention.push(format!( + "{} said {} from {} and couldn't prove it", + plural(d.failed, "message", "messages"), + if d.failed == 1 { "it was" } else { "they were" }, + domain + )); + let mut top: Vec<_> = d.sources.iter().collect(); + top.sort_by(|a, b| b.1.cmp(a.1)); + lines.push(format!( + "{domain}: {} failed from {}; receivers delivered {}, sent {} to spam and rejected {}.", + plural(d.failed, "message", "messages"), + plural(d.sources.len() as u64, "server", "servers"), + number(d.delivered), + number(d.quarantined), + number(d.rejected) + )); + lines.push(format!( + " Most from: {}", + top.iter() + .take(3) + .map(|(ip, n)| format!("{ip} ({})", number(**n))) + .collect::>() + .join(", ") + )); + for (ip, n) in &d.sources { + rows.push(vec![domain.clone(), ip.clone(), n.to_string()]); + } + } + Ok(Some(Part { + title: "Mail pretending to be you", + lines, + csv: Some(( + "spoofing.csv".into(), + csv(&["domain", "source_ip", "failed_messages"], &rows), + )), + link: "Management/x:DmarcExternalReport", + })) +} + +fn tls_kind(kind: TlsResultType) -> &'static str { + match kind { + TlsResultType::StartTlsNotSupported => "the server didn't offer encryption", + TlsResultType::CertificateHostMismatch => "the certificate doesn't cover the MX name", + TlsResultType::CertificateExpired => "the certificate had expired", + TlsResultType::CertificateNotTrusted => "the certificate wasn't trusted", + TlsResultType::ValidationFailure => "the certificate couldn't be validated", + TlsResultType::TlsaInvalid => "DANE (TLSA) records don't match", + TlsResultType::DnssecInvalid => "DNSSEC didn't validate", + TlsResultType::DaneRequired => "DANE was required but unavailable", + TlsResultType::StsPolicyFetchError => "the MTA-STS policy couldn't be fetched", + TlsResultType::StsPolicyInvalid => "the MTA-STS policy is invalid", + TlsResultType::StsWebpkiInvalid => "the MTA-STS site's certificate wasn't valid", + _ => "another TLS problem", + } +} + +/// RP-4. +async fn tls_failures( + server: &Server, + tenant: Option, + from: u64, + to: u64, + attention: &mut Vec, +) -> trc::Result> { + let mut domains: BTreeMap> = BTreeMap::new(); + let mut rows = Vec::new(); + for inner in received(server, ObjectType::TlsExternalReport, tenant, from, to).await? { + let ObjectInner::TlsExternalReport(r) = inner else { + continue; + }; + for policy in r.report.policies.iter() { + for failure in policy.failure_details.iter() { + if failure.failed_session_count == 0 { + continue; + } + let kind = tls_kind(failure.result_type); + *domains + .entry(policy.policy_domain.to_lowercase()) + .or_default() + .entry(kind) + .or_default() += failure.failed_session_count; + rows.push(vec![ + policy.policy_domain.to_lowercase(), + failure.result_type.as_str().to_string(), + failure.failed_session_count.to_string(), + failure.receiving_mx_hostname.clone().unwrap_or_default(), + ]); + } + } + } + if domains.is_empty() { + return Ok(None); + } + let mut lines = Vec::new(); + for (domain, kinds) in &domains { + let total: u64 = kinds.values().sum(); + attention.push(format!( + "{} to {} failed TLS", + plural(total, "delivery", "deliveries"), + domain + )); + for (kind, n) in kinds { + lines.push(format!( + "{domain}: {kind} ({})", + plural(*n, "connection", "connections") + )); + } + } + Ok(Some(Part { + title: "Secure delivery to you", + lines, + csv: Some(( + "tls-failures.csv".into(), + csv(&["domain", "result", "failed_sessions", "mx"], &rows), + )), + link: "Management/x:TlsExternalReport", + })) +} + +/// RP-5: the server-side list of what counts as a Fail, keyed so the next +/// run can say what changed. +fn failing_facts(reports: &[dlv::Report]) -> BTreeMap { + let mut out = BTreeMap::new(); + for report in reports { + for a in &report.addresses { + for l in a + .listings + .iter() + .filter(|l| l.state == ListingState::Listed) + { + out.insert( + format!("ip:{}:list:{}", a.ip, l.list), + format!("{} ({}) is listed on {}", a.ip, report.hostname, l.list), + ); + } + if a.ptr.is_empty() { + out.insert( + format!("ip:{}:ptr", a.ip), + format!("{} ({}) has no reverse DNS", a.ip, report.hostname), + ); + } else if !a.forward_confirmed { + out.insert( + format!("ip:{}:fcrdns", a.ip), + format!("{}'s reverse DNS doesn't point back to it", a.ip), + ); + } + } + for d in &report.domains { + for l in d + .listings + .iter() + .filter(|l| l.state == ListingState::Listed) + { + out.insert( + format!("domain:{}:list:{}", d.domain, l.list), + format!("{} is listed on {}", d.domain, l.list), + ); + } + for s in d.spf.iter().filter(|s| s.result != "pass") { + out.insert( + format!("domain:{}:spf:{}", d.domain, s.ip), + format!("SPF for {} doesn't let {} send", d.domain, s.ip), + ); + } + for k in &d.dkim { + match k.state { + DkimState::Missing => { + out.insert( + format!("domain:{}:dkim:{}", d.domain, k.selector), + format!("{}'s DKIM key {} isn't in DNS", d.domain, k.selector), + ); + } + DkimState::Different => { + out.insert( + format!("domain:{}:dkim:{}", d.domain, k.selector), + format!( + "{}'s DKIM key {} in DNS isn't the one signing", + d.domain, k.selector + ), + ); + } + _ => {} + } + } + if d.mta_sts.record_id.is_some() && !d.mta_sts.fetched { + out.insert( + format!("domain:{}:mta-sts", d.domain), + format!("{}'s MTA-STS policy can't be fetched", d.domain), + ); + } + } + for c in report.certificates.iter().filter(|c| !c.covered) { + out.insert( + format!("cert:{}", c.name), + format!("No certificate covers {}", c.name), + ); + } + } + out +} + +async fn deliverability( + server: &Server, + tenant: Option, + before: &[String], + attention: &mut Vec, +) -> trc::Result<(Option, Vec)> { + let mut reports = dlv::reports(server.store()).await?; + if let Some(tenant) = tenant { + reports = reports.iter().map(|r| r.for_tenant(tenant)).collect(); + } + let now = failing_facts(&reports); + let before: BTreeSet<&str> = before.iter().map(|s| s.as_str()).collect(); + let mut lines = Vec::new(); + let mut rows = Vec::new(); + for (key, text) in &now { + let new = !before.contains(key.as_str()); + if new { + attention.push(format!("Deliverability: {text}")); + } + lines.push(format!("{}{text}", if new { "New: " } else { "Still: " })); + rows.push(vec![ + key.clone(), + text.clone(), + if new { "new" } else { "still" }.into(), + ]); + } + for key in before.iter().filter(|k| !now.contains_key(**k)) { + lines.push(format!("Fixed: {key}")); + rows.push(vec![key.to_string(), String::new(), "fixed".into()]); + } + let failing = now.keys().cloned().collect(); + if lines.is_empty() { + return Ok((None, failing)); + } + Ok(( + Some(Part { + title: "Deliverability", + lines, + csv: Some(( + "deliverability.csv".into(), + csv(&["finding", "detail", "change"], &rows), + )), + link: "Management/CustomComponent/Deliverability", + }), + failing, + )) +} + +/// RP-6. +async fn security(server: &Server, from: u64, to: u64) -> trc::Result> { + const NAMES: [&str; 6] = [ + "auth.failed", + "security.authentication-ban", + "security.abuse-ban", + "security.scan-ban", + "security.loiter-ban", + "security.ip-blocked", + ]; + let len = to - from; + let now = counters(server, from, to, &NAMES).await?; + let before = counters(server, from.saturating_sub(len), from, &NAMES).await?; + if now.iter().all(|n| *n == 0) { + return Ok(None); + } + let bans: u64 = now[1..5].iter().sum(); + let bans_before: u64 = before[1..5].iter().sum(); + let mut lines = Vec::new(); + if now[0] > 0 { + lines.push(format!( + "Failed sign-ins: {}{}", + number(now[0]), + change(now[0], before[0]) + )); + } + if bans > 0 { + lines.push(format!( + "Addresses banned: {}{} (sign-in {}, abuse {}, scanning {}, loitering {})", + number(bans), + change(bans, bans_before), + number(now[1]), + number(now[2]), + number(now[3]), + number(now[4]) + )); + } + if now[5] > 0 { + lines.push(format!( + "Connections from blocked addresses: {}{}", + number(now[5]), + change(now[5], before[5]) + )); + } + Ok(Some(Part { + title: "Security", + lines, + csv: None, + link: "Management/CustomComponent/Dashboard", + })) +} + +/// RP-7. +async fn storage( + server: &Server, + tenant: Option, + from: u64, + to: u64, + attention: &mut Vec, +) -> trc::Result> { + let mut full = Vec::new(); + let mut domains = DomainNames::default(); + for id in server + .registry() + .query::>(RegistryQuery::new(ObjectType::Account)) + .await? + { + let Some(Account::User(user)) = server.registry().object::(id).await? else { + continue; + }; + if let Some(tenant) = tenant + && user.member_tenant_id != Some(Id::from(tenant)) + { + continue; + } + let Some(limit) = user + .quotas + .get(&StorageQuota::MaxDiskQuota) + .copied() + .filter(|q| *q > 0) + else { + continue; + }; + let used = server.get_used_quota_account(id.id() as u32).await?.max(0) as u64; + if (used as f64) / (limit as f64) >= QUOTA_WARN { + let domain = domains + .get(server, user.domain_id) + .await? + .unwrap_or_default(); + full.push((format!("{}@{}", user.name, domain), used, limit)); + } + } + full.sort_by(|a, b| { + (b.1 as f64 / b.2 as f64) + .partial_cmp(&(a.1 as f64 / a.2 as f64)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let mut lines = Vec::new(); + if !full.is_empty() { + attention.push(format!( + "{} at 90% or more of their storage", + plural(full.len() as u64, "person is", "people are") + )); + for (address, used, limit) in full.iter().take(10) { + lines.push(format!( + "{address}: {:.0}% full ({} of {})", + *used as f64 / *limit as f64 * 100.0, + size(*used), + size(*limit) + )); + } + if full.len() > 10 { + lines.push(format!( + "…and {} more (in the attachment).", + full.len() - 10 + )); + } + } + if tenant.is_none() { + for (name, what) in [("user.count", "people"), ("domain.count", "domains")] { + if let Some((first, last)) = gauge(server, from, to, name).await? + && first != last + { + lines.push(format!( + "{}: {} (was {})", + if what == "people" { + "People" + } else { + "Domains" + }, + number(last), + number(first) + )); + } + } + } + if lines.is_empty() { + return Ok(None); + } + let rows: Vec<_> = full + .iter() + .map(|(a, u, l)| vec![a.clone(), u.to_string(), l.to_string()]) + .collect(); + Ok(Some(Part { + title: "Storage", + lines, + csv: (!rows.is_empty()).then(|| { + ( + "storage.csv".into(), + csv(&["address", "used_bytes", "limit_bytes"], &rows), + ) + }), + link: "Management/x:Account/User", + })) +} + +fn size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +/// RP-8. +async fn certificates( + server: &Server, + to: u64, + attention: &mut Vec, +) -> trc::Result> { + let mut soon = Vec::new(); + for id in server + .registry() + .query::>(RegistryQuery::new(ObjectType::Certificate)) + .await? + { + let Some(cert) = server.registry().object::(id).await? else { + continue; + }; + let expires = cert.not_valid_after.timestamp().max(0) as u64; + if expires < to + CERT_DAYS * 86_400 { + let name = cert + .subject_alternative_names + .iter() + .next() + .cloned() + .unwrap_or_else(|| "a certificate".into()); + soon.push((name, expires)); + } + } + if soon.is_empty() { + return Ok(None); + } + soon.sort_by_key(|(_, at)| *at); + let mut lines = Vec::new(); + for (name, at) in &soon { + let days = at.saturating_sub(to) / 86_400; + let line = if *at <= to { + format!("{name}: expired") + } else { + format!("{name}: expires in {}", plural(days, "day", "days")) + }; + attention.push(format!("Certificate {line}")); + lines.push(line); + } + Ok(Some(Part { + title: "Certificates", + lines, + csv: None, + link: "Settings/x:Certificate", + })) +} + +/// RP-19: the report for a period as files, without mailing anyone: the +/// summary as text, and a CSV for each section that has rows. A download +/// doesn't move the deliverability baseline the next mail compares with. +pub async fn export_files( + server: &Server, + report: &Report, + from: u64, + to: u64, +) -> trc::Result)>> { + let built = build(server, report, from, to).await?; + let mut summary = format!("{}\n{}\n\n", report.name, model::period_label(from, to)); + if built.parts.is_empty() { + summary.push_str("Nothing to report for this period.\n"); + } + for part in &built.parts { + summary.push_str(&format!("{}\n", part.title)); + for line in &part.lines { + summary.push_str(&format!(" {line}\n")); + } + summary.push('\n'); + } + let mut files = vec![("summary.txt".to_string(), summary.into_bytes())]; + for part in built.parts { + if let Some((name, body)) = part.csv { + files.push((name, body.into_bytes())); + } + } + Ok(files) +} + +// --- The mail ------------------------------------------------------------- + +fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn compose( + report: &Report, + built: &Built, + from: u64, + to: u64, + from_name: &str, + from_address: &str, + recipients: &[String], +) -> Vec { + let period = model::period_label(from, to); + let admin = std::env::var("INBUXA_ADMIN_URL") + .ok() + .map(|u| u.trim_end_matches('/').to_string()) + .filter(|u| u.starts_with("https://") || u.starts_with("http://")); + let mut text = String::new(); + let mut html = String::from( + "
", + ); + html.push_str(&format!( + "

{}

{}

", + escape(&report.name), + escape(&period) + )); + text.push_str(&format!("{}\n{}\n\n", report.name, period)); + + if built.parts.is_empty() { + // RP-9, Decision 6 + let line = "Nothing to report for this period."; + text.push_str(line); + text.push('\n'); + html.push_str(&format!("

{line}

")); + } else { + if !built.attention.is_empty() { + text.push_str("Needs your attention\n"); + html.push_str("
Needs your attention
    "); + for line in &built.attention { + text.push_str(&format!("- {line}\n")); + html.push_str(&format!("
  • {}
  • ", escape(line))); + } + text.push('\n'); + html.push_str("
"); + } + for part in &built.parts { + text.push_str(&format!("{}\n", part.title)); + html.push_str(&format!( + "

{}

    ", + escape(part.title) + )); + for line in &part.lines { + text.push_str(&format!(" {line}\n")); + html.push_str(&format!("
  • {}
  • ", escape(line))); + } + html.push_str("
"); + if let Some(admin) = &admin { + let url = format!("{admin}/{}", part.link); + text.push_str(&format!(" {url}\n")); + html.push_str(&format!( + "

Open in the console

", + escape(&url) + )); + } + text.push('\n'); + } + } + let footer = "Sent by your inbuxa server. Change or turn off this report in the console under Reports › Scheduled."; + text.push_str(&format!("--\n{footer}\n")); + html.push_str(&format!( + "

{footer}

" + )); + + let mut builder = MessageBuilder::new() + .from(Address::new_address( + Some(from_name.to_string()), + from_address.to_string(), + )) + .to(recipients + .iter() + .map(|r| Address::new_address(None::, r.clone())) + .collect::>()) + .subject(format!("{}: {}", report.name, period)) + .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .text_body(text) + .html_body(html); + if report.attach_csv { + for part in &built.parts { + if let Some((name, body)) = &part.csv { + builder = builder.attachment("text/csv", name.clone(), body.clone().into_bytes()); + } + } + } + builder.write_to_vec().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_features::deliverability::{ + Address as DlvAddress, DomainReport, Listing, SpfResult, + }; + + #[test] + fn numbers_and_changes() { + assert_eq!(number(1234567), "1,234,567"); + assert_eq!(number(12), "12"); + assert_eq!(change(110, 100), " (up 10%)"); + assert_eq!(change(50, 100), " (down 50%)"); + assert_eq!(change(5, 0), ""); + assert_eq!(plural(1, "message", "messages"), "1 message"); + assert_eq!(size(1536), "1.5 KB"); + } + + #[test] + fn csv_quotes_what_needs_it() { + assert_eq!( + csv(&["a", "b"], &[vec!["x,y".into(), "say \"hi\"".into()]]), + "a,b\r\n\"x,y\",\"say \"\"hi\"\"\"\r\n" + ); + } + + #[test] + fn failing_facts_are_keyed_for_changes() { + let report = dlv::Report { + node_id: 1, + hostname: "mx.example.org".into(), + addresses: vec![DlvAddress { + ip: "192.0.2.1".into(), + ptr: vec![], + listings: vec![Listing { + list: "Spamhaus ZEN".into(), + state: ListingState::Listed, + ..Listing::default() + }], + ..DlvAddress::default() + }], + domains: vec![DomainReport { + domain: "example.org".into(), + spf: vec![SpfResult { + ip: "192.0.2.1".into(), + result: "fail".into(), + }], + ..DomainReport::default() + }], + ..dlv::Report::default() + }; + let facts = failing_facts(&[report]); + assert_eq!( + facts.keys().cloned().collect::>(), + vec![ + "domain:example.org:spf:192.0.2.1", + "ip:192.0.2.1:list:Spamhaus ZEN", + "ip:192.0.2.1:ptr" + ] + ); + } + + #[test] + fn quiet_period_mail_says_so() { + let report = model::Report::digest(0); + let built = Built { + attention: vec![], + parts: vec![], + failing: None, + }; + let raw = compose( + &report, + &built, + 1_790_000_000, + 1_790_604_800, + "inbuxa reports", + "postmaster@example.org", + &["admin@example.org".into()], + ); + let message = mail_parser::MessageParser::default().parse(&raw).unwrap(); + assert!(message.subject().unwrap().starts_with("Weekly digest: ")); + assert!( + message + .body_text(0) + .unwrap() + .contains("Nothing to report for this period.") + ); + assert!(String::from_utf8_lossy(&raw).contains("Auto-Submitted: auto-generated")); + } +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 6ff6f70..4f4c17d 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -27,6 +27,7 @@ pub mod broadcast; pub mod inbuxa_lock_expiry; pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1 pub mod inbuxa_deliverability; // inbuxa: the deliverability check +pub mod inbuxa_scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod state_manager; pub mod task_manager; @@ -78,6 +79,9 @@ impl SpawnServices for IpcReceivers { // inbuxa: deliverability spec, DL-14: each node checks itself daily inbuxa_deliverability::spawn_deliverability(inner.clone()); + // inbuxa: scheduled-reports spec, RP-16: every node looks for due reports + inbuxa_scheduled_reports::spawn_scheduled_reports(inner.clone()); + // Spawn task scheduler spawn_task_scheduler(inner); } diff --git a/crates/smtp/src/reporting/inbuxa_send.rs b/crates/smtp/src/reporting/inbuxa_send.rs new file mode 100644 index 0000000..cce3879 --- /dev/null +++ b/crates/smtp/src/reporting/inbuxa_send.rs @@ -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()) + } +} diff --git a/crates/smtp/src/reporting/mod.rs b/crates/smtp/src/reporting/mod.rs index 20e2064..5d2c2ee 100644 --- a/crates/smtp/src/reporting/mod.rs +++ b/crates/smtp/src/reporting/mod.rs @@ -15,6 +15,7 @@ pub mod dkim; pub mod dmarc; pub mod inbound; pub mod index; +pub mod inbuxa_send; // inbuxa: signed server-built mail (scheduled-reports spec, RP-14) pub mod scheduler; pub mod send; pub mod shared; // inbuxa: reports written by every node diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index e75d1e5..09436b3 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -377,6 +377,8 @@ Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions. +Not a rebuild: **scheduled reports and the weekly digest** are INBUXA's own design (inbuxa-drafts `specs/scheduled-reports.md`). An administrator picks sections, a schedule in a time zone and recipients on this server; every node looks for due reports once a minute, one claims each run with the task lock, and the report is built from data the server already keeps and mailed DKIM-signed: `inbuxa:ScheduledReport`, `inbuxa:ScheduledReportSettings` and `inbuxa:ReportExport` (a download: a ZIP of a summary and CSVs) (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default. + ## 5. The web front ends **Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs, diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 8ea0832..9b358d6 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -178,6 +178,36 @@ default = "none" file = "inbuxa_deliverability_settings.rs" default = "none" +[object."inbuxa:ScheduledReport"] +file = "inbuxa_scheduled_report.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "tenant" +retention = "object-life" +[object."inbuxa:ScheduledReport".properties] +recipients = ["contact"] + +[object."inbuxa:ReportExport"] +file = "inbuxa_report_export.rs" +default = "none" +whose = ["administrator", "holder", "correspondent"] +where = ["blob-store"] +scope = "tenant" +retention = { setting = "x:Jmap.uploadTtl" } +[object."inbuxa:ReportExport".properties] +blobId = ["contact", "network", "metadata"] + +[object."inbuxa:ScheduledReportSettings"] +file = "inbuxa_scheduled_report_settings.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:ScheduledReportSettings".properties] +fromAddress = ["contact"] + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" @@ -298,6 +328,18 @@ captures = ["x:DataRetention.expungeTrashAfter", "x:DataRetention.expungeSubmiss leaves_host = false written_by = ["crates/email/src/message/ingest.rs", "crates/groupware/src/calendar/storage.rs", "crates/groupware/src/contact/storage.rs", "crates/groupware/src/file/storage.rs"] +# Scheduled reports are built when they're sent and not stored; the mail +# lands in administrators' own mailboxes on this server (scheduled-reports +# spec, RP-23), so it doesn't leave the host. +[source."scheduled-report-mail"] +categories = ["contact", "network", "metadata"] +whose = ["holder", "correspondent", "administrator"] +where = ["data-store", "blob-store"] +scope = "tenant" +retention = "receiver" +leaves_host = false +written_by = ["crates/services/src/inbuxa_scheduled_reports.rs"] + [source."full-text-index"] categories = ["content", "identifier", "contact", "metadata"] whose = ["holder", "correspondent"] diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index a355b65..9f29577 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 9c4a8fd..8da7aa5 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U \ No newline at end of file +F0Ba3aWEThPbP2e1Uy6eryGOZ-UxeLsPNJn9y2yGp4Y \ No newline at end of file diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 9b71645..f8550a1 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -18,6 +18,7 @@ pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules pub mod security_acceptances; // inbuxa: accepted security to-do items pub mod deliverability; // inbuxa: the deliverability check +pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod journal; // inbuxa: journaling pub mod audit; // inbuxa: the audit log pub mod authorization; diff --git a/tests/src/system/scheduled_reports.rs b/tests/src/system/scheduled_reports.rs new file mode 100644 index 0000000..6c6806c --- /dev/null +++ b/tests/src/system/scheduled_reports.rs @@ -0,0 +1,401 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Scheduled reports and the weekly digest (scheduled-reports spec): the +//! digest every server has, making and changing reports, who they may go +//! to, Send now, and what a tenant administrator sees. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; +use std::time::{Duration, Instant}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +async fn reports(account: &Account) -> Vec { + 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("admin@example.com"); + let me = "admin@example.com"; + + // --- 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!(["someone@elsewhere.example"]); + 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::() + .unwrap(); + admin.create_dkim_signatures(domain_id).await; + let (_, response) = call( + admin, + "inbuxa:ScheduledReportSettings/set", + json!({"update": {"singleton": {"fromAddress": "reports@example.com"}}}), + ) + .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"], "reports@example.com", "{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( + "tadmin@reports.example.org", + "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": ["tadmin@reports.example.org"] + }}}), + ) + .await; + let mine = response["created"]["mine"]["id"] + .as_str() + .unwrap_or_else(|| panic!("tenant report not created: {response}")) + .to_string(); + let seen = reports(&t_admin).await; + assert_eq!(seen.len(), 1, "{seen:?}"); + assert_eq!(seen[0]["memberTenantId"], tenant.to_string(), "{seen:?}"); + // The system administrator sees it too, and the tenant can't touch theirs + assert!( + reports(admin) + .await + .iter() + .any(|r| r["id"] == mine.as_str()) + ); + let (_, response) = call( + &t_admin, + "inbuxa:ScheduledReport/set", + json!({"update": {&id: {"enabled": false}}}), + ) + .await; + assert!(response["notUpdated"][&id].is_object(), "{response}"); + let (name, response) = call( + &t_admin, + "inbuxa:ScheduledReportSettings/set", + json!({"update": {"singleton": {"fromName": "Tenant"}}}), + ) + .await; + assert_eq!(name, "error", "a tenant changed the sender: {response}"); + + // --- Download (RP-19) ---------------------------------------------------- + let (_, response) = call( + admin, + "inbuxa:ReportExport/set", + json!({"create": { + "last": {"reportId": &id}, + "old": {"reportId": &id, "from": "2020-01-01T00:00:00Z", "to": "2020-01-02T00:00:00Z"} + }}), + ) + .await; + let export = &response["created"]["last"]; + assert!(export["blobId"].is_string(), "{response}"); + assert!( + export["size"].as_u64().unwrap_or_default() > 0, + "{response}" + ); + assert_eq!( + export["sha256"].as_str().map(|s| s.len()), + Some(64), + "{response}" + ); + assert_eq!(export["files"][0], "summary.txt", "{response}"); + assert!( + response["notCreated"]["old"].is_object(), + "a 2020 period was exported: {response}" + ); + // A tenant administrator can't download a report that isn't theirs + let (_, response) = call( + &t_admin, + "inbuxa:ReportExport/set", + json!({"create": {"theirs": {"reportId": &id}}}), + ) + .await; + assert!(response["notCreated"]["theirs"].is_object(), "{response}"); + + // --- Deleting ------------------------------------------------------------ + let (_, response) = call( + admin, + "inbuxa:ScheduledReport/set", + json!({"destroy": [&id, &mine]}), + ) + .await; + assert_eq!( + response["destroyed"].as_array().map(|d| d.len()), + Some(2), + "{response}" + ); + // Put the digest back as it was for the tests that follow + call( + admin, + "inbuxa:ScheduledReport/set", + json!({"update": {&digest_id: {"enabled": true, "schedule": { + "frequency": "weekly", "weekday": 1, "hour": 7, "minute": 0, "timeZone": "UTC" + }}}}), + ) + .await; +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn scheduled_reports_tests() { + let mut test = TestServerBuilder::new("scheduled_reports_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +}