Compare commits

..
Author SHA1 Message Date
jcoffey-dev e442c3a770 Merge pull request 'Release 2026.10.6.3' (#163) from release/2026.10.6.3-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 34m30s
announce / announce (release) Successful in 9s
github/ci (tag) GitHub Actions
publish / github (push) Successful in 1h5m13s
publish / announce (push) Failing after 23s
2026-10-07 06:23:16 +00:00
jcoffey-dev 20b9411687 Release 2026.10.6.3
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m45s
2026-10-06 23:15:00 -07:00
jcoffey-dev d7d94904d5 Merge pull request 'Scheduled reports: a feature byte of their own' (#162) from fix/scheduled-reports-own-prefix into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Canceled after 8m49s
2026-10-07 06:14:36 +00:00
jcoffey-dev 3510b28ce1 Scheduled reports: a feature byte of their own
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 9m6s
2026.10.6.2 kept scheduled reports under S in the fork's subspace, a
byte scale-out storage (Sb, Sm, Sl, replica markers Sr + index) and the
spam-rules updater (the Sr marker, Sf, Sn) already use. Nothing was
overwritten (the keys differ in length), but a scan of Sr + id could
read the others' keys, and one byte per feature is the rule. They move
to G, unused anywhere.

ensure_digest moves what 2026.10.6.2 wrote, once per process: what
reads as a report from the Sr + 8-byte-id range, and the settings at
Ss, deleting only those exact keys. The system test writes a legacy
digest, legacy settings and the spam marker, and checks the first two
moved and the marker didn't.
2026-10-06 23:05:14 -07:00
jcoffey-dev dba15be9f0 Merge pull request 'Release 2026.10.6.2' (#161) from release/2026.10.6.2-pr into main
ci / fork-checks (push) Skipped
ci / build (push) Skipped
publish / version (push) Skipped
publish / publish-amd64 (push) Skipped
publish / publish-arm64 (push) Skipped
publish / release (push) Skipped
publish / binaries (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 42m29s
announce / announce (release) Successful in 23s
github/ci (tag) GitHub Actions
publish / github (push) Failing after 1h15m51s
publish / announce (push) Skipped
2026-10-06 22:53:28 +00:00
jcoffey-dev 9723d745cb Release 2026.10.6.2
github/ci (branch) GitHub Actions
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Successful in 4s
2026-10-06 15:41:44 -07:00
jcoffey-dev dc4525fee7 Merge pull request 'Scheduled reports and the weekly digest' (#160) from feature/scheduled-reports into main
ci / github (push) Canceled after 12m29s
ci / fork-checks (push) Skipped
ci / build (push) Skipped
github/ci (branch) GitHub Actions
2026-10-06 22:41:05 +00:00
jcoffey-dev 43b9f93ff7 Scheduled reports: a Management › Reports › Scheduled menu link
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
ci / github (pull_request) Successful in 8m45s
github/ci (branch) GitHub Actions
The console's page for scheduled reports, first under Reports; the
mail's footer points there.
2026-10-06 15:25:44 -07:00
jcoffey-dev 09ccad4b4b Scheduled reports: Download, a report for a period as a ZIP
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Canceled after 1m19s
inbuxa:ReportExport/set creates {reportId, from?, to?}: the report built
for that period (its last full one by default, at most 90 days back, as
far as metrics are kept) as a ZIP of summary.txt and the section CSVs,
stored as an upload, mailing nobody. Reading needs sysScheduledReportGet,
as seeing the report does; a tenant administrator downloads only their
own; every download is in the audit log. A download doesn't move the
deliverability baseline the next mail compares with.
2026-10-06 15:24:34 -07:00
jcoffey-dev ff5480cb55 Scheduled reports and the weekly digest
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m25s
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.
2026-10-06 14:41:28 -07:00
34 changed files with 4080 additions and 7 deletions

No files matched your search

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