From ff5480cb5521e0149afb7d5769fedd8cb3b4604c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 14:41:28 -0700 Subject: [PATCH] Scheduled reports and the weekly digest An administrator picks sections, a schedule (daily, weekly or monthly, at a time in a time zone) and recipients, who must be accounts on this server. Every node looks for due reports once a minute; a run is claimed with the task lock, keyed by report and due time, and recorded on the report, so it goes once. The report is built from what the server already keeps: mail flow, the queue, spoofing from received DMARC reports, TLS failures, deliverability findings and what changed since the last run, security counters, people near their quota, and expiring certificates. It is mailed as text and HTML with optional CSV attachments, DKIM-signed; a sender domain without a key fails the run with that reason instead of sending unsigned. The weekly digest is a built-in report on every server, on by default: every section, Mondays 07:00 UTC, to the system administrators. It can be changed or turned off, not deleted. A tenant administrator makes and sees only their own tenant's reports, which leave out server-wide sections. New: inbuxa:ScheduledReport and inbuxa:ScheduledReportSettings, the sysScheduledReportGet and sysScheduledReportUpdate permissions (granted once to existing administrator roles), privacy catalog entries, and a system test. Spec: inbuxa-drafts specs/scheduled-reports.md. --- Cargo.lock | 2 + crates/common/src/auth/permissions.rs | 6 + .../common/src/manager/granted_permissions.rs | 12 +- crates/features/Cargo.toml | 3 + crates/features/src/lib.rs | 1 + crates/features/src/scheduled_reports/mod.rs | 621 ++++++++ .../src/object/inbuxa_scheduled_report.rs | 190 +++ .../inbuxa_scheduled_report_settings.rs | 159 ++ crates/jmap-proto/src/object/mod.rs | 2 + crates/jmap-proto/src/references/eval.rs | 6 + crates/jmap-proto/src/references/resolve.rs | 8 + crates/jmap-proto/src/request/method.rs | 14 + crates/jmap-proto/src/request/mod.rs | 4 + crates/jmap-proto/src/request/parser.rs | 28 + crates/jmap-proto/src/response/mod.rs | 26 + crates/jmap/src/api/auth.rs | 21 + crates/jmap/src/api/request.rs | 62 + crates/jmap/src/changes/get.rs | 2 + crates/jmap/src/inbuxa/mod.rs | 1 + crates/jmap/src/inbuxa/scheduled_reports.rs | 535 +++++++ crates/registry/src/schema/enums.rs | 3 + crates/registry/src/schema/enums_impl.rs | 8 +- .../services/src/inbuxa_scheduled_reports.rs | 1280 +++++++++++++++++ crates/services/src/lib.rs | 4 + crates/smtp/src/reporting/inbuxa_send.rs | 45 + crates/smtp/src/reporting/mod.rs | 1 + docs/spec/SPEC.md | 2 + resources/privacy/catalog.toml | 32 + resources/schema/schema.json.gz | Bin 153576 -> 153603 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/mod.rs | 1 + tests/src/system/scheduled_reports.rs | 366 +++++ 32 files changed, 3441 insertions(+), 6 deletions(-) create mode 100644 crates/features/src/scheduled_reports/mod.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_scheduled_report.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_scheduled_report_settings.rs create mode 100644 crates/jmap/src/inbuxa/scheduled_reports.rs create mode 100644 crates/services/src/inbuxa_scheduled_reports.rs create mode 100644 crates/smtp/src/reporting/inbuxa_send.rs create mode 100644 tests/src/system/scheduled_reports.rs 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_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..c244db6 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -33,6 +33,8 @@ pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check +pub mod inbuxa_scheduled_report; // inbuxa: scheduled reports +pub mod inbuxa_scheduled_report_settings; // inbuxa: scheduled reports pub mod inbuxa_journal; // inbuxa: journaling pub mod inbuxa_journal_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..117d05d 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -97,6 +97,12 @@ impl Response<'_> { GetResponseMethod::DeliverabilitySettings(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..6910ab0 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -58,6 +58,8 @@ impl Response<'_> { GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?, GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?, + GetRequestMethod::ScheduledReport(request) => request.resolve_references(self)?, + GetRequestMethod::ScheduledReportSettings(request) => request.resolve_references(self)?, GetRequestMethod::Journal(request) => request.resolve_references(self)?, GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, @@ -148,6 +150,12 @@ impl Response<'_> { SetRequestMethod::DeliverabilitySettings(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..3fe41c5 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -73,6 +73,8 @@ pub enum MethodObject { // inbuxa: the deliverability check DeliverabilityReport, DeliverabilitySettings, + ScheduledReport, + ScheduledReportSettings, HeldMessage, // inbuxa: journaling Journal, @@ -124,6 +126,8 @@ impl MethodObject { | MethodObject::HeldMessage | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | MethodObject::ScheduledReport + | MethodObject::ScheduledReportSettings | MethodObject::Journal | MethodObject::JournalEntry | MethodObject::JournalExport @@ -332,6 +336,10 @@ 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::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 +518,10 @@ impl MethodName { "inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set), "inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get), "inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set), + "inbuxa:ScheduledReport/get" => (MethodObject::ScheduledReport, MethodFunction::Get), + "inbuxa:ScheduledReport/set" => (MethodObject::ScheduledReport, MethodFunction::Set), + "inbuxa:ScheduledReportSettings/get" => (MethodObject::ScheduledReportSettings, MethodFunction::Get), + "inbuxa:ScheduledReportSettings/set" => (MethodObject::ScheduledReportSettings, MethodFunction::Set), "inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get), "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get), @@ -595,6 +607,8 @@ impl Display for MethodObject { MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport", MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings", + 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..74fa294 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -128,6 +128,8 @@ pub enum GetRequestMethod { SecurityAcceptance(Box>), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ScheduledReport(Box>), + ScheduledReportSettings(Box>), Journal(Box>), JournalEntry(Box>), HeldMessage(Box>), @@ -176,6 +178,8 @@ pub enum SetRequestMethod<'x> { ), DeliverabilityReport(Box>), DeliverabilitySettings(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..e15094d 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -715,6 +715,34 @@ 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)); + } + }, // 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..9f0b58f 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -115,6 +115,8 @@ pub enum GetResponseMethod { SecurityAcceptance(GetResponse), DeliverabilityReport(GetResponse), DeliverabilitySettings(GetResponse), + ScheduledReport(GetResponse), + ScheduledReportSettings(GetResponse), Journal(GetResponse), JournalEntry(GetResponse), HeldMessage(GetResponse), @@ -163,6 +165,8 @@ pub enum SetResponseMethod { ), DeliverabilityReport(Box>), DeliverabilitySettings(Box>), + ScheduledReport(Box>), + ScheduledReportSettings(Box>), Journal(Box>), JournalExport(Box>), JournalVerification(Box>), @@ -892,6 +896,28 @@ impl<'x> From 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..d199856 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -127,6 +127,10 @@ 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(_) => 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 +360,21 @@ 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: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -529,6 +548,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | 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..8d4fc5e 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -299,6 +299,12 @@ impl RequestHandler for Server { SetResponseMethod::DeliverabilitySettings(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 +564,19 @@ 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() + } // inbuxa: journaling GetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -1086,6 +1105,49 @@ 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() + } + 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..e37c942 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -434,6 +434,8 @@ impl IntermediateChangesResponse { | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings + | 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..0696da7 --- /dev/null +++ b/crates/jmap/src/inbuxa/scheduled_reports.rs @@ -0,0 +1,535 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings` +//! (scheduled-reports spec). +//! +//! Reading needs `sysScheduledReportGet`, changing (and Send now, RP-18) +//! `sysScheduledReportUpdate`. A tenant administrator sees and changes only +//! their own tenant's reports, and a report they create is their tenant's +//! (RP-22). The weekly digest can be changed or turned off, not deleted, and +//! its recipients are the system administrators (RP-21). Recipients must be +//! accounts on this server (RP-23). + +use common::{Server, auth::AccessToken}; +use inbuxa_features::scheduled_reports::{self as model, Report, RunStatus, Schedule, Section}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::{ + inbuxa_scheduled_report::{ + ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue, + }, + inbuxa_scheduled_report_settings::{ + ScheduledReportSettings, ScheduledReportSettingsProperty as S, + ScheduledReportSettingsValue, + }, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Map, Property, Value}; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +const REPORT: &[R] = &[ + R::Id, + R::Name, + R::Enabled, + R::BuiltIn, + R::Sections, + R::Schedule, + R::Recipients, + R::AttachCsv, + R::MemberTenantId, + R::CreatedAt, + R::NextRunAt, + R::Runs, +]; + +const SETTINGS: &[S] = &[S::Id, S::FromName, S::FromAddress]; + +fn json_to_value(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) +} 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..fb8ac8a --- /dev/null +++ b/crates/services/src/inbuxa_scheduled_reports.rs @@ -0,0 +1,1280 @@ +/* + * 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", + })) +} + +// --- 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 reports."; + 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..5398f14 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` and `inbuxa:ScheduledReportSettings` (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default. + ## 5. The web front ends **Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs, diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 8ea0832..9ac68ac 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -178,6 +178,26 @@ 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: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 +318,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 a355b6522801a254b9e8b99f9eae2ed4545f4e86..7d2a51a991953976350750fb82c6f7ea0bb62bde 100644 GIT binary patch delta 19087 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

*`F2_ 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®Ljso*wQ93GFr~}J!8dW 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 S>bkV${(k|H^vNxq`3?Xij7MSs diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 9c4a8fd..7d0da93 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -OUcXqvEO48gT6mdw9zVPWfZ-QfMKFj5xvxa-0iE4L9U \ No newline at end of file +u-6dUvjU7hT6J8izT4-NlIPmmmcdzFOSwixb72qJWRg \ 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..9c87b12 --- /dev/null +++ b/tests/src/system/scheduled_reports.rs @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Scheduled reports and the weekly digest (scheduled-reports spec): the +//! digest every server has, making and changing reports, who they may go +//! to, Send now, and what a tenant administrator sees. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; +use std::time::{Duration, Instant}; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +async fn reports(account: &Account) -> Vec { + 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}"); + + // --- 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(); + } +}