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.
This commit is contained in:
jcoffey-dev committed 2026-10-06 14:41:28 -07:00
1 parent b62713bb8d
commit ff5480cb55
32 files changed
+3441 -6

No files matched your search

+6
View File
@@ -317,6 +317,12 @@ impl Default for DefaultPermissions {
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
default.superuser.push(permission);
}
// inbuxa: scheduled-reports spec, RP-22: a tenant administrator
// makes reports for their own tenant, which the server limits
Permission::SysScheduledReportGet | Permission::SysScheduledReportUpdate => {
default.superuser.push(permission);
default.tenant.push(permission);
}
// inbuxa: DLP and mail flow rules, and held mail, are the
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
// settled answer 3)
@@ -32,8 +32,8 @@ use types::id::Id;
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
/// the data inventory (personal-data catalog spec), accepting security
/// to-do items (security to-do list spec), and the deliverability check
/// (deliverability spec).
/// to-do items (security to-do list spec), the deliverability check
/// (deliverability spec), and scheduled reports (scheduled-reports spec).
const ADMIN_GRANTS: &[Permission] = &[
Permission::SysAiExplain,
Permission::SysAuditGet,
@@ -60,6 +60,8 @@ const ADMIN_GRANTS: &[Permission] = &[
Permission::SysDeliverabilityGet,
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityCheck,
Permission::SysScheduledReportGet,
Permission::SysScheduledReportUpdate,
];
/// Granted to the server-level Compliance Officer role once it exists:
@@ -77,8 +79,8 @@ const OFFICER_GRANTS: &[Permission] = &[
/// Granted to the default tenant administrator roles: reading and exporting
/// the tenant's audit log (AU-9), locking and delegating its accounts
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
/// deliverability findings (DL-20).
/// (AL-12), the tenant's slice of the data inventory, its own domains'
/// deliverability findings (DL-20), and its own scheduled reports (RP-22).
const TENANT_GRANTS: &[Permission] = &[
Permission::SysAuditGet,
Permission::SysAuditExport,
@@ -88,6 +90,8 @@ const TENANT_GRANTS: &[Permission] = &[
Permission::SysAccountLockDestroy,
Permission::SysComplianceGet,
Permission::SysDeliverabilityGet,
Permission::SysScheduledReportGet,
Permission::SysScheduledReportUpdate,
];
#[derive(Clone, Copy, PartialEq, Eq)]
+3
View File
@@ -28,6 +28,9 @@ zip = "8.6"
quick-xml = "0.41"
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-builder = { version = "1.0" }
# inbuxa: scheduled reports run at a local time (scheduled-reports spec, RP-15)
chrono = { version = "0.4", default-features = false, features = ["std"] }
chrono-tz = "0.10"
[dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] }
+1
View File
@@ -28,6 +28,7 @@ pub mod lock;
pub mod mailflow;
pub mod masked_email;
pub mod privacy;
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest (not a rebuild)
pub mod security;
pub mod tenancy;
pub mod undelete;
@@ -0,0 +1,621 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Scheduled reports and the weekly digest (scheduled-reports spec).
//!
//! An administrator picks sections, a schedule in a time zone and who gets
//! it; the server builds the report at that time from data it already keeps
//! and mails it. The weekly digest is a built-in report (RP-21).
//!
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
//! with `S`, then one byte for the kind:
//!
//! - `r` + report id (u64): a report, as JSON.
//! - `s`: the settings, as JSON.
//!
//! Numbers are big-endian.
use chrono::{Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
const FEATURE: u8 = b'S';
const KIND_REPORT: u8 = b'r';
const KIND_SETTINGS: u8 = b's';
/// The weekly digest's id (RP-21); other reports count up from here.
pub const DIGEST_ID: u64 = 1;
/// RP-23.
pub const MAX_RECIPIENTS: usize = 50;
/// RP-16: runs kept per report.
pub const KEEP_RUNS: usize = 20;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerdeSerialize, SerdeDeserialize,
)]
#[serde(rename_all = "camelCase")]
pub enum Section {
MailFlow,
Queue,
Spoofing,
TlsFailures,
Deliverability,
Security,
Storage,
Certificates,
}
impl Section {
pub const ALL: [Section; 8] = [
Section::MailFlow,
Section::Queue,
Section::Spoofing,
Section::TlsFailures,
Section::Deliverability,
Section::Security,
Section::Storage,
Section::Certificates,
];
pub fn as_str(&self) -> &'static str {
match self {
Section::MailFlow => "mailFlow",
Section::Queue => "queue",
Section::Spoofing => "spoofing",
Section::TlsFailures => "tlsFailures",
Section::Deliverability => "deliverability",
Section::Security => "security",
Section::Storage => "storage",
Section::Certificates => "certificates",
}
}
pub fn parse(value: &str) -> Option<Self> {
Section::ALL.into_iter().find(|s| s.as_str() == value)
}
/// RP-12: what a tenant's report leaves out, being server-wide.
pub fn server_wide(&self) -> bool {
matches!(
self,
Section::MailFlow | Section::Queue | Section::Security | Section::Certificates
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Frequency {
Daily,
#[default]
Weekly,
Monthly,
}
/// RP-15.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Schedule {
pub frequency: Frequency,
/// 1 = Monday … 7 = Sunday; weekly only.
pub weekday: u8,
/// 1–28; monthly only.
pub day_of_month: u8,
pub hour: u8,
pub minute: u8,
/// An IANA zone, e.g. "Europe/Amsterdam".
pub time_zone: String,
}
impl Default for Schedule {
fn default() -> Self {
Schedule {
frequency: Frequency::Weekly,
weekday: 1,
day_of_month: 1,
hour: 7,
minute: 0,
time_zone: "UTC".into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RunStatus {
#[default]
Sent,
Failed,
}
/// One time a report went, or tried to (RP-16).
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Run {
pub at: u64,
pub by_hand: bool,
pub status: RunStatus,
pub reason: Option<String>,
pub recipients: u32,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Report {
pub id: u64,
pub name: String,
pub enabled: bool,
/// The weekly digest: can be edited or turned off, not deleted (RP-21).
pub built_in: bool,
pub sections: Vec<Section>,
pub schedule: Schedule,
/// Addresses of accounts on this server (RP-23). The digest's are the
/// system administrators' at send time, and this stays empty.
pub recipients: Vec<String>,
pub attach_csv: bool,
/// RP-22: a tenant's report, limited as RP-12 says.
pub tenant_id: Option<u32>,
pub created_at: u64,
/// The due time of the last run, so a run is never repeated (RP-16).
pub last_due: u64,
pub runs: Vec<Run>,
/// RP-5: what was failing at the last run, to say what changed.
pub failing: Vec<String>,
/// RP-17: scheduled runs that failed in a row.
pub failed_in_a_row: u32,
}
impl Report {
/// The weekly digest as it starts (RP-21, Decision 1: on).
pub fn digest(now: u64) -> Self {
Report {
id: DIGEST_ID,
name: "Weekly digest".into(),
enabled: true,
built_in: true,
sections: Section::ALL.to_vec(),
schedule: Schedule::default(),
recipients: Vec::new(),
attach_csv: false,
tenant_id: None,
created_at: now,
last_due: now,
runs: Vec::new(),
failing: Vec::new(),
failed_in_a_row: 0,
}
}
/// The sections this report covers, less the server-wide ones for a
/// tenant (RP-12).
pub fn effective_sections(&self) -> Vec<Section> {
self.sections
.iter()
.copied()
.filter(|s| self.tenant_id.is_none() || !s.server_wide())
.collect()
}
pub fn push_run(&mut self, run: Run) {
self.runs.insert(0, run);
self.runs.truncate(KEEP_RUNS);
}
/// What an administrator may set, checked (RP-15, RP-23). Whether the
/// recipients are local accounts is checked against the directory.
pub fn validate(&self) -> Result<(), &'static str> {
let name = self.name.trim();
if name.is_empty() || name.chars().count() > 100 {
return Err("A name of 1 to 100 characters.");
}
if self.sections.is_empty() {
return Err("At least one section.");
}
let mut seen = self.sections.clone();
seen.sort();
seen.dedup();
if seen.len() != self.sections.len() {
return Err("Each section once.");
}
self.schedule.validate()?;
if !self.built_in && self.recipients.is_empty() {
return Err("At least one recipient.");
}
if self.recipients.len() > MAX_RECIPIENTS {
return Err("At most 50 recipients.");
}
if self
.recipients
.iter()
.any(|r| r.trim().is_empty() || !r.contains('@'))
{
return Err("Recipients are email addresses.");
}
Ok(())
}
}
impl Schedule {
pub fn validate(&self) -> Result<(), &'static str> {
if self.time_zone.parse::<Tz>().is_err() {
return Err("An IANA time zone, such as Europe/Amsterdam.");
}
if self.hour > 23 || self.minute > 59 {
return Err("A time between 00:00 and 23:59.");
}
match self.frequency {
Frequency::Weekly if !(1..=7).contains(&self.weekday) => {
Err("A weekday from 1 (Monday) to 7 (Sunday).")
}
Frequency::Monthly if !(1..=28).contains(&self.day_of_month) => {
Err("A day of the month from 1 to 28.")
}
_ => Ok(()),
}
}
fn tz(&self) -> Tz {
self.time_zone.parse().unwrap_or(chrono_tz::UTC)
}
fn matches(&self, date: NaiveDate) -> bool {
match self.frequency {
Frequency::Daily => true,
Frequency::Weekly => date.weekday().number_from_monday() == self.weekday as u32,
Frequency::Monthly => date.day() == self.day_of_month as u32,
}
}
/// The schedule's instant on a local date. A time skipped by a clock
/// change goes at the first moment after it; a repeated one, the first time.
fn instant_on(&self, date: NaiveDate) -> Option<i64> {
let tz = self.tz();
let local = date.and_hms_opt(self.hour as u32, self.minute as u32, 0)?;
match tz.from_local_datetime(&local) {
LocalResult::Single(t) => Some(t.timestamp()),
LocalResult::Ambiguous(first, _) => Some(first.timestamp()),
LocalResult::None => (1..=4).find_map(|h| {
tz.from_local_datetime(&(local + Duration::minutes(30 * h)))
.earliest()
.map(|t| t.timestamp())
}),
}
}
/// The first scheduled instant strictly after `after` (Unix seconds).
pub fn next_due(&self, after: u64) -> Option<u64> {
let tz = self.tz();
let start = Utc
.timestamp_opt(after as i64, 0)
.single()?
.with_timezone(&tz)
.date_naive();
(0..62)
.filter_map(|d| start.checked_add_signed(Duration::days(d)))
.filter(|date| self.matches(*date))
.filter_map(|date| self.instant_on(date))
.find(|ts| *ts > after as i64)
.map(|ts| ts as u64)
}
/// The period a run due at `due` covers: the day, week or month before it.
pub fn period(&self, due: u64) -> (u64, u64) {
let from = match self.frequency {
Frequency::Daily => due.saturating_sub(86_400),
Frequency::Weekly => due.saturating_sub(7 * 86_400),
Frequency::Monthly => {
let tz = self.tz();
Utc.timestamp_opt(due as i64, 0)
.single()
.map(|t| t.with_timezone(&tz).date_naive())
.and_then(|date| date.checked_sub_months(chrono::Months::new(1)))
.and_then(|date| self.instant_on(date))
.map(|ts| ts as u64)
.unwrap_or(due.saturating_sub(30 * 86_400))
}
};
(from, due)
}
}
/// "Sep 29 – Oct 5, 2026": a period ends at its due time, so the last day
/// covered is the one before.
pub fn period_label(from: u64, to: u64) -> String {
let fmt = |ts: u64, year: bool| {
Utc.timestamp_opt(ts as i64, 0)
.single()
.map(|t| {
t.format(if year { "%b %-d, %Y" } else { "%b %-d" })
.to_string()
})
.unwrap_or_default()
};
format!("{} – {}", fmt(from, false), fmt(to.saturating_sub(1), true))
}
/// RP-20.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Settings {
/// Empty means "inbuxa reports".
pub from_name: Option<String>,
/// Empty means postmaster at the server's default domain.
pub from_address: Option<String>,
}
impl Settings {
pub fn from_name(&self) -> &str {
self.from_name
.as_deref()
.filter(|n| !n.trim().is_empty())
.unwrap_or("inbuxa reports")
}
}
// --- Storage --------------------------------------------------------------
struct Json<T>(T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize a scheduled report")
.reason(err)
})
}
}
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid scheduled report")
.reason(err)
})
}
}
fn class(kind: u8, id: Option<u64>) -> ValueClass {
let mut key = Vec::with_capacity(10);
key.push(FEATURE);
key.push(kind);
if let Some(id) = id {
key.extend_from_slice(&id.to_be_bytes());
}
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
pub async fn report(data: &Store, id: u64) -> trc::Result<Option<Report>> {
Ok(data
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(id))))
.await
.caused_by(trc::location!())?
.map(|Json(report)| report))
}
/// Every report, by id.
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
let mut out = Vec::new();
data.iterate(
IterateParams::new(
ValueKey::from(class(KIND_REPORT, Some(0))),
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
),
|_, value| {
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
out.push(report);
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
out.sort_by_key(|r| r.id);
Ok(out)
}
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(
class(KIND_REPORT, Some(report.id)),
Json(report).serialize()?,
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
pub async fn delete_report(data: &Store, id: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(class(KIND_REPORT, Some(id)));
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
/// The id for a new report: one above the highest.
pub fn next_id(reports: &[Report]) -> u64 {
reports
.iter()
.map(|r| r.id)
.max()
.unwrap_or(DIGEST_ID)
.max(DIGEST_ID)
+ 1
}
/// Every server has the digest (RP-21); written the first time it's missed.
pub async fn ensure_digest(data: &Store, now: u64) -> trc::Result<()> {
if report(data, DIGEST_ID).await?.is_none() {
put_report(data, &Report::digest(now)).await?;
}
Ok(())
}
pub async fn settings(data: &Store) -> trc::Result<Settings> {
Ok(data
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
.await
.caused_by(trc::location!())?
.map(|Json(settings)| settings)
.unwrap_or_default())
}
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
data.write(batch.build_all())
.await
.caused_by(trc::location!())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(s: &str) -> u64 {
chrono::DateTime::parse_from_rfc3339(s).unwrap().timestamp() as u64
}
fn weekly(tz: &str) -> Schedule {
Schedule {
time_zone: tz.into(),
..Schedule::default()
}
}
#[test]
fn weekly_goes_monday_at_seven_local() {
let s = weekly("Europe/Amsterdam");
// Wednesday 2026-10-07 → Monday 2026-10-12 07:00 CEST (05:00Z)
assert_eq!(
s.next_due(ts("2026-10-07T12:00:00Z")),
Some(ts("2026-10-12T05:00:00Z"))
);
// Exactly at the due time: the next one, a week on
assert_eq!(
s.next_due(ts("2026-10-12T05:00:00Z")),
Some(ts("2026-10-19T05:00:00Z"))
);
// After the clocks go back (25 Oct): 07:00 CET is 06:00Z
assert_eq!(
s.next_due(ts("2026-10-20T00:00:00Z")),
Some(ts("2026-10-26T06:00:00Z"))
);
}
#[test]
fn daily_and_monthly() {
let daily = Schedule {
frequency: Frequency::Daily,
hour: 23,
minute: 30,
..weekly("UTC")
};
assert_eq!(
daily.next_due(ts("2026-10-06T23:30:00Z")),
Some(ts("2026-10-07T23:30:00Z"))
);
let monthly = Schedule {
frequency: Frequency::Monthly,
day_of_month: 28,
..weekly("America/Phoenix")
};
// 28 Oct 07:00 MST (no DST in Phoenix) = 14:00Z
assert_eq!(
monthly.next_due(ts("2026-10-06T00:00:00Z")),
Some(ts("2026-10-28T14:00:00Z"))
);
// Its period is the month before
assert_eq!(
monthly.period(ts("2026-10-28T14:00:00Z")),
(ts("2026-09-28T14:00:00Z"), ts("2026-10-28T14:00:00Z"))
);
}
#[test]
fn a_time_the_clocks_skip_goes_just_after() {
let s = Schedule {
frequency: Frequency::Daily,
hour: 2,
minute: 30,
..weekly("Europe/Amsterdam")
};
// 29 Mar 2026: 02:00–03:00 doesn't exist; 03:00 CEST = 01:00Z
assert_eq!(
s.next_due(ts("2026-03-28T12:00:00Z")),
Some(ts("2026-03-29T01:00:00Z"))
);
}
#[test]
fn validation() {
let mut r = Report {
name: "Ops".into(),
sections: vec![Section::Storage],
recipients: vec!["[email protected]".into()],
..Report::default()
};
assert_eq!(r.validate(), Ok(()));
r.schedule.time_zone = "Mars/Olympus".into();
assert!(r.validate().is_err());
r.schedule.time_zone = "UTC".into();
r.recipients.clear();
assert!(r.validate().is_err());
r.recipients = vec!["[email protected]".into(); 51];
assert!(r.validate().is_err());
r.recipients = vec!["[email protected]".into()];
r.sections = vec![Section::Storage, Section::Storage];
assert!(r.validate().is_err());
// The digest needs no recipients of its own
assert_eq!(Report::digest(0).validate(), Ok(()));
}
#[test]
fn tenants_lose_the_server_wide_sections() {
let mut r = Report::digest(0);
r.tenant_id = Some(3);
assert_eq!(
r.effective_sections(),
vec![
Section::Spoofing,
Section::TlsFailures,
Section::Deliverability,
Section::Storage
]
);
}
#[test]
fn ids_and_runs() {
assert_eq!(next_id(&[]), 2);
assert_eq!(next_id(&[Report::digest(0)]), 2);
let mut r = Report::digest(0);
for at in 0..30 {
r.push_run(Run {
at,
..Run::default()
});
}
assert_eq!(r.runs.len(), KEEP_RUNS);
assert_eq!(r.runs[0].at, 29);
}
}
@@ -0,0 +1,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
}
}
+2
View File
@@ -33,6 +33,8 @@ pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
pub mod inbuxa_scheduled_report; // inbuxa: scheduled reports
pub mod inbuxa_scheduled_report_settings; // inbuxa: scheduled reports
pub mod inbuxa_journal; // inbuxa: journaling
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
pub mod inbuxa_held_message; // inbuxa: mail held for review
+6
View File
@@ -97,6 +97,12 @@ impl Response<'_> {
GetResponseMethod::DeliverabilitySettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::ScheduledReport(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::ScheduledReportSettings(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Journal(response) => {
response.eval_jptr(path, &mut results)
}
@@ -58,6 +58,8 @@ impl Response<'_> {
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?,
GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?,
GetRequestMethod::ScheduledReport(request) => request.resolve_references(self)?,
GetRequestMethod::ScheduledReportSettings(request) => request.resolve_references(self)?,
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
@@ -148,6 +150,12 @@ impl Response<'_> {
SetRequestMethod::DeliverabilitySettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::ScheduledReport(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::ScheduledReportSettings(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::Journal(request) => {
request.resolve_references(self, 1, false)?
}
+14
View File
@@ -73,6 +73,8 @@ pub enum MethodObject {
// inbuxa: the deliverability check
DeliverabilityReport,
DeliverabilitySettings,
ScheduledReport,
ScheduledReportSettings,
HeldMessage,
// inbuxa: journaling
Journal,
@@ -124,6 +126,8 @@ impl MethodObject {
| MethodObject::HeldMessage
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
@@ -332,6 +336,10 @@ impl MethodName {
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set",
(MethodFunction::Get, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/get",
(MethodFunction::Set, MethodObject::ScheduledReport) => "inbuxa:ScheduledReport/set",
(MethodFunction::Get, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/get",
(MethodFunction::Set, MethodObject::ScheduledReportSettings) => "inbuxa:ScheduledReportSettings/set",
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
@@ -510,6 +518,10 @@ impl MethodName {
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set),
"inbuxa:ScheduledReport/get" => (MethodObject::ScheduledReport, MethodFunction::Get),
"inbuxa:ScheduledReport/set" => (MethodObject::ScheduledReport, MethodFunction::Set),
"inbuxa:ScheduledReportSettings/get" => (MethodObject::ScheduledReportSettings, MethodFunction::Get),
"inbuxa:ScheduledReportSettings/set" => (MethodObject::ScheduledReportSettings, MethodFunction::Set),
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
@@ -595,6 +607,8 @@ impl Display for MethodObject {
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
MethodObject::ScheduledReport => "inbuxa:ScheduledReport",
MethodObject::ScheduledReportSettings => "inbuxa:ScheduledReportSettings",
MethodObject::Journal => "inbuxa:Journal",
MethodObject::JournalEntry => "inbuxa:JournalEntry",
MethodObject::JournalExport => "inbuxa:JournalExport",
+4
View File
@@ -128,6 +128,8 @@ pub enum GetRequestMethod {
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
ScheduledReport(Box<GetRequest<crate::object::inbuxa_scheduled_report::ScheduledReport>>),
ScheduledReportSettings(Box<GetRequest<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
@@ -176,6 +178,8 @@ pub enum SetRequestMethod<'x> {
),
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
ScheduledReport(Box<SetRequest<'x, crate::object::inbuxa_scheduled_report::ScheduledReport>>),
ScheduledReportSettings(Box<SetRequest<'x, crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
+28
View File
@@ -715,6 +715,34 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::ScheduledReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::ScheduledReport) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReport(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::ScheduledReportSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ScheduledReportSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::ScheduledReportSettings) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ScheduledReportSettings(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
// inbuxa: journaling
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
+26
View File
@@ -115,6 +115,8 @@ pub enum GetResponseMethod {
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
ScheduledReport(GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>),
ScheduledReportSettings(GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>),
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
@@ -163,6 +165,8 @@ pub enum SetResponseMethod {
),
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
ScheduledReport(Box<SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>>),
ScheduledReportSettings(Box<SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>>),
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
@@ -892,6 +896,28 @@ impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::Deliver
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>) -> Self {
ResponseMethod::Get(GetResponseMethod::ScheduledReport(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_scheduled_report::ScheduledReport>) -> Self {
ResponseMethod::Set(SetResponseMethod::ScheduledReport(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>) -> Self {
ResponseMethod::Get(GetResponseMethod::ScheduledReportSettings(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>> for ResponseMethod<'x> {
fn from(value: SetResponse<crate::object::inbuxa_scheduled_report_settings::ScheduledReportSettings>) -> Self {
ResponseMethod::Set(SetResponseMethod::ScheduledReportSettings(Box::new(value)))
}
}
// inbuxa: accepted security to-do items
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
+21
View File
@@ -127,6 +127,10 @@ impl JmapAuthorization for AccessToken {
// page that shows the findings, so they read the same way
GetRequestMethod::DeliverabilityReport(_)
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
// inbuxa: scheduled-reports spec; a tenant administrator sees
// their own tenant's reports (RP-22)
GetRequestMethod::ScheduledReport(_)
| GetRequestMethod::ScheduledReportSettings(_) => Permission::SysScheduledReportGet,
// inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
@@ -356,6 +360,21 @@ impl JmapAuthorization for AccessToken {
Permission::SysDeliverabilityUpdate,
Permission::SysDeliverabilityUpdate,
),
// inbuxa: scheduled reports; Send now is an update (RP-18)
SetRequestMethod::ScheduledReport(s) => validate_set(
s,
self,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
),
SetRequestMethod::ScheduledReportSettings(s) => validate_set(
s,
self,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
Permission::SysScheduledReportUpdate,
),
// inbuxa: LH-12, exporting held data
SetRequestMethod::HoldExport(s) => validate_set(
s,
@@ -529,6 +548,8 @@ impl JmapAuthorization for AccessToken {
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::HeldMessage
| MethodObject::Journal
| MethodObject::JournalEntry
+62
View File
@@ -299,6 +299,12 @@ impl RequestHandler for Server {
SetResponseMethod::DeliverabilitySettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::ScheduledReport(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::ScheduledReportSettings(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::Journal(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -558,6 +564,19 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: scheduled reports
GetRequestMethod::ScheduledReport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::scheduled_reports::get_reports(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::ScheduledReportSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::scheduled_reports::get_settings(self, access_token, *req)
.await?
.into()
}
// inbuxa: journaling
GetRequestMethod::Journal(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
@@ -1086,6 +1105,49 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: scheduled reports; every change is in the audit log
SetRequestMethod::ScheduledReport(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| {
Box::pin(crate::inbuxa::scheduled_reports::set_reports(
self,
access_token,
req,
))
},
)
.await?
.into()
}
SetRequestMethod::ScheduledReportSettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::audit::recorded(
self,
access_token,
session,
&method_name.obj.to_string(),
None,
None,
*req,
|req| {
Box::pin(crate::inbuxa::scheduled_reports::set_settings(
self,
access_token,
req,
))
},
)
.await?
.into()
}
// inbuxa: DL-6; which lists are asked is in the audit log
SetRequestMethod::DeliverabilitySettings(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
+2
View File
@@ -434,6 +434,8 @@ impl IntermediateChangesResponse {
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::ScheduledReport
| MethodObject::ScheduledReportSettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
+1
View File
@@ -13,6 +13,7 @@ pub mod legal_hold;
pub mod mail_rule;
pub mod security_acceptance;
pub mod deliverability; // inbuxa: the deliverability check
pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest
pub mod journal;
pub mod journal_entry;
pub mod held_message;
+535
View File
@@ -0,0 +1,535 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings`
//! (scheduled-reports spec).
//!
//! Reading needs `sysScheduledReportGet`, changing (and Send now, RP-18)
//! `sysScheduledReportUpdate`. A tenant administrator sees and changes only
//! their own tenant's reports, and a report they create is their tenant's
//! (RP-22). The weekly digest can be changed or turned off, not deleted, and
//! its recipients are the system administrators (RP-21). Recipients must be
//! accounts on this server (RP-23).
use common::{Server, auth::AccessToken};
use inbuxa_features::scheduled_reports::{self as model, Report, RunStatus, Schedule, Section};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::{
inbuxa_scheduled_report::{
ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue,
},
inbuxa_scheduled_report_settings::{
ScheduledReportSettings, ScheduledReportSettingsProperty as S,
ScheduledReportSettingsValue,
},
},
request::IntoValid,
types::date::UTCDate,
};
use jmap_tools::{Element, Key, Map, Property, Value};
use std::borrow::Cow;
use store::write::now;
use types::id::Id;
const REPORT: &[R] = &[
R::Id,
R::Name,
R::Enabled,
R::BuiltIn,
R::Sections,
R::Schedule,
R::Recipients,
R::AttachCsv,
R::MemberTenantId,
R::CreatedAt,
R::NextRunAt,
R::Runs,
];
const SETTINGS: &[S] = &[S::Id, S::FromName, S::FromAddress];
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => {
if let Some(n) = n.as_u64() {
Value::Number(n.into())
} else if let Some(n) = n.as_i64() {
Value::Number(n.into())
} else {
Value::Number(n.as_f64().unwrap_or_default().into())
}
}
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
serde_json::Value::Array(items) => {
Value::Array(items.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
for (key, value) in map {
out.insert_unchecked(Key::Owned(key), json_to_value(value));
}
Value::Object(out)
}
}
}
fn date<P: Property, E: Element>(seconds: u64) -> Value<'static, P, E> {
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
}
/// Whether this administrator may see or change the report (RP-22).
fn visible(access_token: &AccessToken, report: &Report) -> bool {
match access_token.tenant_id() {
Some(tenant) => report.tenant_id == Some(tenant),
None => true,
}
}
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, ScheduledReportValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
R::Id => Value::Element(ScheduledReportValue::Id(Id::from(report.id))),
R::Name => Value::Str(report.name.clone().into()),
R::Enabled => Value::Bool(report.enabled),
R::BuiltIn => Value::Bool(report.built_in),
R::Sections => Value::Array(
report
.sections
.iter()
.map(|s| Value::Str(s.as_str().into()))
.collect(),
),
R::Schedule => {
json_to_value(serde_json::to_value(&report.schedule).unwrap_or_default())
}
R::Recipients => Value::Array(
report
.recipients
.iter()
.map(|r| Value::Str(r.clone().into()))
.collect(),
),
R::AttachCsv => Value::Bool(report.attach_csv),
R::MemberTenantId => report
.tenant_id
.map(|t| Value::Element(ScheduledReportValue::Id(Id::from(t))))
.unwrap_or(Value::Null),
R::CreatedAt => date(report.created_at),
R::NextRunAt => report
.enabled
.then(|| report.schedule.next_due(report.last_due))
.flatten()
.map(date)
.unwrap_or(Value::Null),
R::Runs => Value::Array(
report
.runs
.iter()
.map(|run| {
json_to_value(serde_json::json!({
"at": UTCDate::from_timestamp(run.at as i64).to_string(),
"byHand": run.by_hand,
"status": match run.status {
RunStatus::Sent => "sent",
RunStatus::Failed => "failed",
},
"reason": run.reason,
"recipients": run.recipients,
"size": run.size,
}))
})
.collect(),
),
R::SendNow => Value::Null,
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:ScheduledReport/get`.
pub async fn get_reports(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<ScheduledReport>,
) -> trc::Result<GetResponse<ScheduledReport>> {
let properties = request.unwrap_properties(REPORT);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
model::ensure_digest(server.store(), now()).await?;
let reports: Vec<Report> = model::reports(server.store())
.await?
.into_iter()
.filter(|r| visible(access_token, r))
.collect();
match ids {
None => {
response.list = reports
.iter()
.map(|r| report_value(r, &properties))
.collect();
}
Some(ids) => {
for id in ids {
match reports.iter().find(|r| r.id == id.id()) {
Some(report) => response.list.push(report_value(report, &properties)),
None => response.push_not_found(id),
}
}
}
}
Ok(response)
}
/// What a create or an update may set, applied onto `report`. Returns
/// whether Send now was asked for.
fn apply(
report: &mut Report,
value: Value<'_, R, ScheduledReportValue>,
) -> Result<bool, SetError<R>> {
let invalid = |property: R, why: &str| {
SetError::invalid_properties()
.with_property(property)
.with_description(why.to_string())
};
let mut send_now = false;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = key else {
return Err(SetError::invalid_properties().with_property(key.into_owned()));
};
let json = serde_json::to_value(&value).unwrap_or_default();
match property {
R::Name => match json.as_str() {
Some(name) => report.name = name.trim().to_string(),
None => return Err(invalid(R::Name, "A name.")),
},
R::Enabled => match json.as_bool() {
Some(enabled) => report.enabled = enabled,
None => return Err(invalid(R::Enabled, "true or false.")),
},
R::AttachCsv => match json.as_bool() {
Some(attach) => report.attach_csv = attach,
None => return Err(invalid(R::AttachCsv, "true or false.")),
},
R::Sections => {
let sections = json.as_array().and_then(|items| {
items
.iter()
.map(|i| i.as_str().and_then(Section::parse))
.collect::<Option<Vec<_>>>()
});
match sections {
Some(sections) => report.sections = sections,
None => return Err(invalid(R::Sections, "A list of section names.")),
}
}
R::Schedule => match serde_json::from_value::<Schedule>(json) {
Ok(schedule) => report.schedule = schedule,
Err(_) => return Err(invalid(R::Schedule, "A schedule.")),
},
R::Recipients => {
let recipients = json.as_array().and_then(|items| {
items
.iter()
.map(|i| i.as_str().map(|s| s.trim().to_lowercase()))
.collect::<Option<Vec<_>>>()
});
match recipients {
Some(r) if report.built_in && !r.is_empty() => {
return Err(invalid(
R::Recipients,
"The weekly digest goes to the system administrators.",
));
}
Some(r) => report.recipients = r,
None => return Err(invalid(R::Recipients, "A list of addresses.")),
}
}
R::SendNow => send_now = json.as_bool().unwrap_or(false),
other => return Err(invalid(other, "The server sets this.")),
}
}
Ok(send_now)
}
/// RP-23: every recipient is an account on this server.
async fn check_recipients(server: &Server, report: &Report) -> trc::Result<Option<String>> {
for address in &report.recipients {
if server.rcpt_id_from_email(address).await?.is_none() {
return Ok(Some(format!(
"{address} isn't an account on this server. Reports only go to accounts here."
)));
}
}
Ok(None)
}
/// `inbuxa:ScheduledReport/set`.
pub async fn set_reports(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, ScheduledReport>,
) -> trc::Result<SetResponse<ScheduledReport>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
let data = server.store();
model::ensure_digest(data, now()).await?;
for (client_id, value) in request.unwrap_create() {
let existing = model::reports(data).await?;
let mut report = Report {
id: model::next_id(&existing),
enabled: true,
tenant_id: access_token.tenant_id(),
created_at: now(),
last_due: now(),
..Report::default()
};
let send_now = match apply(&mut report, value) {
Ok(send_now) => send_now,
Err(err) => {
response.not_created.append(client_id, err);
continue;
}
};
if let Err(why) = report.validate() {
response.not_created.append(
client_id,
SetError::invalid_properties().with_description(why),
);
continue;
}
if let Some(why) = check_recipients(server, &report).await? {
response.not_created.append(
client_id,
SetError::invalid_properties()
.with_property(R::Recipients)
.with_description(why),
);
continue;
}
model::put_report(data, &report).await?;
if send_now {
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
}
response
.created
.insert(client_id, report_value(&report, &[R::Id, R::NextRunAt]));
}
for (id, value) in request.unwrap_update().into_valid() {
let Some(mut report) = model::report(data, id.id())
.await?
.filter(|r| visible(access_token, r))
else {
response.not_updated.append(id, SetError::not_found());
continue;
};
let schedule_before = report.schedule.clone();
let send_now = match apply(&mut report, value) {
Ok(send_now) => send_now,
Err(err) => {
response.not_updated.append(id, err);
continue;
}
};
if let Err(why) = report.validate() {
response
.not_updated
.append(id, SetError::invalid_properties().with_description(why));
continue;
}
if let Some(why) = check_recipients(server, &report).await? {
response.not_updated.append(
id,
SetError::invalid_properties()
.with_property(R::Recipients)
.with_description(why),
);
continue;
}
// A new schedule counts from now, not from the last run
if report.schedule != schedule_before {
report.last_due = report.last_due.max(now());
}
model::put_report(data, &report).await?;
if send_now {
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
}
response.updated.append(id, None);
}
for id in request.unwrap_destroy().into_valid() {
match model::report(data, id.id())
.await?
.filter(|r| visible(access_token, r))
{
None => response.not_destroyed.append(id, SetError::not_found()),
Some(report) if report.built_in => response.not_destroyed.append(
id,
SetError::forbidden()
.with_description("The weekly digest can be turned off, not deleted."),
),
Some(_) => {
model::delete_report(data, id.id()).await?;
response.destroyed.push(id);
}
}
}
Ok(response)
}
fn settings_value(
settings: &model::Settings,
default_address: &str,
properties: &[S],
) -> Value<'static, S, ScheduledReportSettingsValue> {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
S::Id => Value::Element(ScheduledReportSettingsValue::Id(Id::singleton())),
S::FromName => Value::Str(settings.from_name().to_string().into()),
S::FromAddress => Value::Str(
settings
.from_address
.clone()
.unwrap_or_else(|| default_address.to_string())
.into(),
),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
fn default_address(server: &Server) -> String {
format!("postmaster@{}", server.core.email.default_domain_name)
}
/// `inbuxa:ScheduledReportSettings/get`.
pub async fn get_settings(
server: &Server,
_access_token: &AccessToken,
mut request: GetRequest<ScheduledReportSettings>,
) -> trc::Result<GetResponse<ScheduledReportSettings>> {
let properties = request.unwrap_properties(SETTINGS);
let (ids, not_found) = request.unwrap_ids(1)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let settings = model::settings(server.store()).await?;
let default = default_address(server);
match ids {
None => response
.list
.push(settings_value(&settings, &default, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response
.list
.push(settings_value(&settings, &default, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
/// `inbuxa:ScheduledReportSettings/set`: the server's, not a tenant's.
pub async fn set_settings(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, ScheduledReportSettings>,
) -> trc::Result<SetResponse<ScheduledReportSettings>> {
if access_token.tenant_id().is_some() {
return Err(trc::JmapEvent::Forbidden
.into_err()
.details("Who reports come from is the server's."));
}
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response
.not_created
.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
let data = server.store();
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() {
response.not_updated.append(id, SetError::not_found());
continue;
}
let mut settings = model::settings(data).await?;
let mut error = None;
for (key, value) in value.into_expanded_object() {
let json = serde_json::to_value(&value).unwrap_or_default();
match &key {
Key::Property(S::FromName) => {
settings.from_name = json
.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
Key::Property(S::FromAddress) => {
match json.as_str().map(|s| s.trim().to_lowercase()) {
Some(a) if a.is_empty() => settings.from_address = None,
Some(a) if a.contains('@') && !a.ends_with('@') => {
settings.from_address = Some(a)
}
_ => {
error = Some(
SetError::invalid_properties()
.with_property(S::FromAddress)
.with_description("An email address."),
);
break;
}
}
}
Key::Property(property) => {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description("The server sets this."),
);
break;
}
_ => {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
}
}
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
model::put_settings(data, &settings).await?;
response.updated.append(id, None);
}
}
}
Ok(response)
}
+3
View File
@@ -1766,6 +1766,9 @@ pub enum Permission {
SysDeliverabilityGet = 685,
SysDeliverabilityUpdate = 686,
SysDeliverabilityCheck = 687,
// inbuxa: scheduled reports and the weekly digest
SysScheduledReportGet = 688,
SysScheduledReportUpdate = 689,
SysAccountGet = 219,
SysAccountCreate = 220,
SysAccountUpdate = 221,
+7 -1
View File
@@ -7105,6 +7105,8 @@ impl EnumImpl for Permission {
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
b"sysScheduledReportGet" => Permission::SysScheduledReportGet,
b"sysScheduledReportUpdate" => Permission::SysScheduledReportUpdate,
b"sysAccountGet" => Permission::SysAccountGet,
b"sysAccountCreate" => Permission::SysAccountCreate,
b"sysAccountUpdate" => Permission::SysAccountUpdate,
@@ -7809,6 +7811,8 @@ impl EnumImpl for Permission {
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
Permission::SysScheduledReportGet => "sysScheduledReportGet",
Permission::SysScheduledReportUpdate => "sysScheduledReportUpdate",
Permission::SysAccountGet => "sysAccountGet",
Permission::SysAccountCreate => "sysAccountCreate",
Permission::SysAccountUpdate => "sysAccountUpdate",
@@ -8506,6 +8510,8 @@ impl EnumImpl for Permission {
685 => Some(Permission::SysDeliverabilityGet),
686 => Some(Permission::SysDeliverabilityUpdate),
687 => Some(Permission::SysDeliverabilityCheck),
688 => Some(Permission::SysScheduledReportGet),
689 => Some(Permission::SysScheduledReportUpdate),
219 => Some(Permission::SysAccountGet),
220 => Some(Permission::SysAccountCreate),
221 => Some(Permission::SysAccountUpdate),
@@ -8950,7 +8956,7 @@ impl EnumImpl for Permission {
}
}
const COUNT: usize = 688;
const COUNT: usize = 690;
}
impl serde::Serialize for Permission {
File diff suppressed because it is too large. Load diff
+4
View File
@@ -27,6 +27,7 @@ pub mod broadcast;
pub mod inbuxa_lock_expiry;
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
pub mod inbuxa_scheduled_reports; // inbuxa: scheduled reports and the weekly digest
pub mod state_manager;
pub mod task_manager;
@@ -78,6 +79,9 @@ impl SpawnServices for IpcReceivers {
// inbuxa: deliverability spec, DL-14: each node checks itself daily
inbuxa_deliverability::spawn_deliverability(inner.clone());
// inbuxa: scheduled-reports spec, RP-16: every node looks for due reports
inbuxa_scheduled_reports::spawn_scheduled_reports(inner.clone());
// Spawn task scheduler
spawn_task_scheduler(inner);
}
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: a server-built message, always DKIM-signed (scheduled-reports
//! spec, RP-14). Unlike `send_autogenerated`, a message that can't be signed
//! isn't sent, and the caller hears why.
use crate::queue::{
MessageSource,
spool::{QueueParams, SmtpSpool},
};
use common::Server;
/// Queues `raw` from `from` to `rcpts`, signed with `sign_domain`'s keys.
pub async fn send_signed(
server: &Server,
from: &str,
rcpts: &[String],
raw: &[u8],
sign_domain: &str,
) -> Result<(), String> {
let signers = match server.dkim_signers(sign_domain).await {
Ok(Some(signers)) => signers,
Ok(None) => return Err(format!("{sign_domain} has no DKIM key to sign with.")),
Err(err) => {
trc::error!(err.details("Failed to retrieve DKIM signers for a report"));
return Err(format!("The DKIM keys for {sign_domain} couldn't be read."));
}
};
let mut message = server.new_message(from, MessageSource::Autogenerated, 0);
for rcpt in rcpts {
message.add_expanded_recipient(rcpt, server).await;
}
if message
.queue(QueueParams::new(raw, 0, server).with_dkim_signers(Some(signers)))
.await
{
Ok(())
} else {
Err("The mail queue didn't accept the message.".into())
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod dkim;
pub mod dmarc;
pub mod inbound;
pub mod index;
pub mod inbuxa_send; // inbuxa: signed server-built mail (scheduled-reports spec, RP-14)
pub mod scheduler;
pub mod send;
pub mod shared; // inbuxa: reports written by every node