Scheduled reports and the weekly digest
An administrator picks sections, a schedule (daily, weekly or monthly, at a time in a time zone) and recipients, who must be accounts on this server. Every node looks for due reports once a minute; a run is claimed with the task lock, keyed by report and due time, and recorded on the report, so it goes once. The report is built from what the server already keeps: mail flow, the queue, spoofing from received DMARC reports, TLS failures, deliverability findings and what changed since the last run, security counters, people near their quota, and expiring certificates. It is mailed as text and HTML with optional CSV attachments, DKIM-signed; a sender domain without a key fails the run with that reason instead of sending unsigned. The weekly digest is a built-in report on every server, on by default: every section, Mondays 07:00 UTC, to the system administrators. It can be changed or turned off, not deleted. A tenant administrator makes and sees only their own tenant's reports, which leave out server-wide sections. New: inbuxa:ScheduledReport and inbuxa:ScheduledReportSettings, the sysScheduledReportGet and sysScheduledReportUpdate permissions (granted once to existing administrator roles), privacy catalog entries, and a system test. Spec: inbuxa-drafts specs/scheduled-reports.md.
This commit is contained in:
1 parent
b62713bb8d
commit
ff5480cb55
32 files changed
+3441
-6
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>>),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -434,6 +434,8 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::DeliverabilityReport
|
||||
| MethodObject::DeliverabilitySettings
|
||||
| MethodObject::ScheduledReport
|
||||
| MethodObject::ScheduledReportSettings
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:ScheduledReport` and `inbuxa:ScheduledReportSettings`
|
||||
//! (scheduled-reports spec).
|
||||
//!
|
||||
//! Reading needs `sysScheduledReportGet`, changing (and Send now, RP-18)
|
||||
//! `sysScheduledReportUpdate`. A tenant administrator sees and changes only
|
||||
//! their own tenant's reports, and a report they create is their tenant's
|
||||
//! (RP-22). The weekly digest can be changed or turned off, not deleted, and
|
||||
//! its recipients are the system administrators (RP-21). Recipients must be
|
||||
//! accounts on this server (RP-23).
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::scheduled_reports::{self as model, Report, RunStatus, Schedule, Section};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::{
|
||||
inbuxa_scheduled_report::{
|
||||
ScheduledReport, ScheduledReportProperty as R, ScheduledReportValue,
|
||||
},
|
||||
inbuxa_scheduled_report_settings::{
|
||||
ScheduledReportSettings, ScheduledReportSettingsProperty as S,
|
||||
ScheduledReportSettingsValue,
|
||||
},
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Map, Property, Value};
|
||||
use std::borrow::Cow;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
const REPORT: &[R] = &[
|
||||
R::Id,
|
||||
R::Name,
|
||||
R::Enabled,
|
||||
R::BuiltIn,
|
||||
R::Sections,
|
||||
R::Schedule,
|
||||
R::Recipients,
|
||||
R::AttachCsv,
|
||||
R::MemberTenantId,
|
||||
R::CreatedAt,
|
||||
R::NextRunAt,
|
||||
R::Runs,
|
||||
];
|
||||
|
||||
const SETTINGS: &[S] = &[S::Id, S::FromName, S::FromAddress];
|
||||
|
||||
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(n) = n.as_u64() {
|
||||
Value::Number(n.into())
|
||||
} else if let Some(n) = n.as_i64() {
|
||||
Value::Number(n.into())
|
||||
} else {
|
||||
Value::Number(n.as_f64().unwrap_or_default().into())
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
|
||||
serde_json::Value::Array(items) => {
|
||||
Value::Array(items.into_iter().map(json_to_value).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, value) in map {
|
||||
out.insert_unchecked(Key::Owned(key), json_to_value(value));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn date<P: Property, E: Element>(seconds: u64) -> Value<'static, P, E> {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
/// Whether this administrator may see or change the report (RP-22).
|
||||
fn visible(access_token: &AccessToken, report: &Report) -> bool {
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant) => report.tenant_id == Some(tenant),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, ScheduledReportValue> {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
R::Id => Value::Element(ScheduledReportValue::Id(Id::from(report.id))),
|
||||
R::Name => Value::Str(report.name.clone().into()),
|
||||
R::Enabled => Value::Bool(report.enabled),
|
||||
R::BuiltIn => Value::Bool(report.built_in),
|
||||
R::Sections => Value::Array(
|
||||
report
|
||||
.sections
|
||||
.iter()
|
||||
.map(|s| Value::Str(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
R::Schedule => {
|
||||
json_to_value(serde_json::to_value(&report.schedule).unwrap_or_default())
|
||||
}
|
||||
R::Recipients => Value::Array(
|
||||
report
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|r| Value::Str(r.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
R::AttachCsv => Value::Bool(report.attach_csv),
|
||||
R::MemberTenantId => report
|
||||
.tenant_id
|
||||
.map(|t| Value::Element(ScheduledReportValue::Id(Id::from(t))))
|
||||
.unwrap_or(Value::Null),
|
||||
R::CreatedAt => date(report.created_at),
|
||||
R::NextRunAt => report
|
||||
.enabled
|
||||
.then(|| report.schedule.next_due(report.last_due))
|
||||
.flatten()
|
||||
.map(date)
|
||||
.unwrap_or(Value::Null),
|
||||
R::Runs => Value::Array(
|
||||
report
|
||||
.runs
|
||||
.iter()
|
||||
.map(|run| {
|
||||
json_to_value(serde_json::json!({
|
||||
"at": UTCDate::from_timestamp(run.at as i64).to_string(),
|
||||
"byHand": run.by_hand,
|
||||
"status": match run.status {
|
||||
RunStatus::Sent => "sent",
|
||||
RunStatus::Failed => "failed",
|
||||
},
|
||||
"reason": run.reason,
|
||||
"recipients": run.recipients,
|
||||
"size": run.size,
|
||||
}))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
R::SendNow => Value::Null,
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:ScheduledReport/get`.
|
||||
pub async fn get_reports(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<ScheduledReport>,
|
||||
) -> trc::Result<GetResponse<ScheduledReport>> {
|
||||
let properties = request.unwrap_properties(REPORT);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
model::ensure_digest(server.store(), now()).await?;
|
||||
let reports: Vec<Report> = model::reports(server.store())
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| visible(access_token, r))
|
||||
.collect();
|
||||
match ids {
|
||||
None => {
|
||||
response.list = reports
|
||||
.iter()
|
||||
.map(|r| report_value(r, &properties))
|
||||
.collect();
|
||||
}
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match reports.iter().find(|r| r.id == id.id()) {
|
||||
Some(report) => response.list.push(report_value(report, &properties)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// What a create or an update may set, applied onto `report`. Returns
|
||||
/// whether Send now was asked for.
|
||||
fn apply(
|
||||
report: &mut Report,
|
||||
value: Value<'_, R, ScheduledReportValue>,
|
||||
) -> Result<bool, SetError<R>> {
|
||||
let invalid = |property: R, why: &str| {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(why.to_string())
|
||||
};
|
||||
let mut send_now = false;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let Key::Property(property) = key else {
|
||||
return Err(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
};
|
||||
let json = serde_json::to_value(&value).unwrap_or_default();
|
||||
match property {
|
||||
R::Name => match json.as_str() {
|
||||
Some(name) => report.name = name.trim().to_string(),
|
||||
None => return Err(invalid(R::Name, "A name.")),
|
||||
},
|
||||
R::Enabled => match json.as_bool() {
|
||||
Some(enabled) => report.enabled = enabled,
|
||||
None => return Err(invalid(R::Enabled, "true or false.")),
|
||||
},
|
||||
R::AttachCsv => match json.as_bool() {
|
||||
Some(attach) => report.attach_csv = attach,
|
||||
None => return Err(invalid(R::AttachCsv, "true or false.")),
|
||||
},
|
||||
R::Sections => {
|
||||
let sections = json.as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i.as_str().and_then(Section::parse))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
});
|
||||
match sections {
|
||||
Some(sections) => report.sections = sections,
|
||||
None => return Err(invalid(R::Sections, "A list of section names.")),
|
||||
}
|
||||
}
|
||||
R::Schedule => match serde_json::from_value::<Schedule>(json) {
|
||||
Ok(schedule) => report.schedule = schedule,
|
||||
Err(_) => return Err(invalid(R::Schedule, "A schedule.")),
|
||||
},
|
||||
R::Recipients => {
|
||||
let recipients = json.as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i.as_str().map(|s| s.trim().to_lowercase()))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
});
|
||||
match recipients {
|
||||
Some(r) if report.built_in && !r.is_empty() => {
|
||||
return Err(invalid(
|
||||
R::Recipients,
|
||||
"The weekly digest goes to the system administrators.",
|
||||
));
|
||||
}
|
||||
Some(r) => report.recipients = r,
|
||||
None => return Err(invalid(R::Recipients, "A list of addresses.")),
|
||||
}
|
||||
}
|
||||
R::SendNow => send_now = json.as_bool().unwrap_or(false),
|
||||
other => return Err(invalid(other, "The server sets this.")),
|
||||
}
|
||||
}
|
||||
Ok(send_now)
|
||||
}
|
||||
|
||||
/// RP-23: every recipient is an account on this server.
|
||||
async fn check_recipients(server: &Server, report: &Report) -> trc::Result<Option<String>> {
|
||||
for address in &report.recipients {
|
||||
if server.rcpt_id_from_email(address).await?.is_none() {
|
||||
return Ok(Some(format!(
|
||||
"{address} isn't an account on this server. Reports only go to accounts here."
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// `inbuxa:ScheduledReport/set`.
|
||||
pub async fn set_reports(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, ScheduledReport>,
|
||||
) -> trc::Result<SetResponse<ScheduledReport>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let data = server.store();
|
||||
model::ensure_digest(data, now()).await?;
|
||||
|
||||
for (client_id, value) in request.unwrap_create() {
|
||||
let existing = model::reports(data).await?;
|
||||
let mut report = Report {
|
||||
id: model::next_id(&existing),
|
||||
enabled: true,
|
||||
tenant_id: access_token.tenant_id(),
|
||||
created_at: now(),
|
||||
last_due: now(),
|
||||
..Report::default()
|
||||
};
|
||||
let send_now = match apply(&mut report, value) {
|
||||
Ok(send_now) => send_now,
|
||||
Err(err) => {
|
||||
response.not_created.append(client_id, err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(why) = report.validate() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties().with_description(why),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(why) = check_recipients(server, &report).await? {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(R::Recipients)
|
||||
.with_description(why),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
model::put_report(data, &report).await?;
|
||||
if send_now {
|
||||
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
|
||||
}
|
||||
response
|
||||
.created
|
||||
.insert(client_id, report_value(&report, &[R::Id, R::NextRunAt]));
|
||||
}
|
||||
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
let Some(mut report) = model::report(data, id.id())
|
||||
.await?
|
||||
.filter(|r| visible(access_token, r))
|
||||
else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
let schedule_before = report.schedule.clone();
|
||||
let send_now = match apply(&mut report, value) {
|
||||
Ok(send_now) => send_now,
|
||||
Err(err) => {
|
||||
response.not_updated.append(id, err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(why) = report.validate() {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::invalid_properties().with_description(why));
|
||||
continue;
|
||||
}
|
||||
if let Some(why) = check_recipients(server, &report).await? {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(R::Recipients)
|
||||
.with_description(why),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// A new schedule counts from now, not from the last run
|
||||
if report.schedule != schedule_before {
|
||||
report.last_due = report.last_due.max(now());
|
||||
}
|
||||
model::put_report(data, &report).await?;
|
||||
if send_now {
|
||||
services::inbuxa_scheduled_reports::send_now(server.clone(), report.id);
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
match model::report(data, id.id())
|
||||
.await?
|
||||
.filter(|r| visible(access_token, r))
|
||||
{
|
||||
None => response.not_destroyed.append(id, SetError::not_found()),
|
||||
Some(report) if report.built_in => response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("The weekly digest can be turned off, not deleted."),
|
||||
),
|
||||
Some(_) => {
|
||||
model::delete_report(data, id.id()).await?;
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn settings_value(
|
||||
settings: &model::Settings,
|
||||
default_address: &str,
|
||||
properties: &[S],
|
||||
) -> Value<'static, S, ScheduledReportSettingsValue> {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
S::Id => Value::Element(ScheduledReportSettingsValue::Id(Id::singleton())),
|
||||
S::FromName => Value::Str(settings.from_name().to_string().into()),
|
||||
S::FromAddress => Value::Str(
|
||||
settings
|
||||
.from_address
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_address.to_string())
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
fn default_address(server: &Server) -> String {
|
||||
format!("postmaster@{}", server.core.email.default_domain_name)
|
||||
}
|
||||
|
||||
/// `inbuxa:ScheduledReportSettings/get`.
|
||||
pub async fn get_settings(
|
||||
server: &Server,
|
||||
_access_token: &AccessToken,
|
||||
mut request: GetRequest<ScheduledReportSettings>,
|
||||
) -> trc::Result<GetResponse<ScheduledReportSettings>> {
|
||||
let properties = request.unwrap_properties(SETTINGS);
|
||||
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let settings = model::settings(server.store()).await?;
|
||||
let default = default_address(server);
|
||||
match ids {
|
||||
None => response
|
||||
.list
|
||||
.push(settings_value(&settings, &default, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response
|
||||
.list
|
||||
.push(settings_value(&settings, &default, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:ScheduledReportSettings/set`: the server's, not a tenant's.
|
||||
pub async fn set_settings(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, ScheduledReportSettings>,
|
||||
) -> trc::Result<SetResponse<ScheduledReportSettings>> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Who reports come from is the server's."));
|
||||
}
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, SetError::singleton());
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(id, SetError::singleton());
|
||||
}
|
||||
let data = server.store();
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
if !id.is_singleton() {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
let mut settings = model::settings(data).await?;
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let json = serde_json::to_value(&value).unwrap_or_default();
|
||||
match &key {
|
||||
Key::Property(S::FromName) => {
|
||||
settings.from_name = json
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
}
|
||||
Key::Property(S::FromAddress) => {
|
||||
match json.as_str().map(|s| s.trim().to_lowercase()) {
|
||||
Some(a) if a.is_empty() => settings.from_address = None,
|
||||
Some(a) if a.contains('@') && !a.ends_with('@') => {
|
||||
settings.from_address = Some(a)
|
||||
}
|
||||
_ => {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(S::FromAddress)
|
||||
.with_description("An email address."),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Key::Property(property) => {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("The server sets this."),
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match error {
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
model::put_settings(data, &settings).await?;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1766,6 +1766,9 @@ pub enum Permission {
|
||||
SysDeliverabilityGet = 685,
|
||||
SysDeliverabilityUpdate = 686,
|
||||
SysDeliverabilityCheck = 687,
|
||||
// inbuxa: scheduled reports and the weekly digest
|
||||
SysScheduledReportGet = 688,
|
||||
SysScheduledReportUpdate = 689,
|
||||
SysAccountGet = 219,
|
||||
SysAccountCreate = 220,
|
||||
SysAccountUpdate = 221,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: a server-built message, always DKIM-signed (scheduled-reports
|
||||
//! spec, RP-14). Unlike `send_autogenerated`, a message that can't be signed
|
||||
//! isn't sent, and the caller hears why.
|
||||
|
||||
use crate::queue::{
|
||||
MessageSource,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
};
|
||||
use common::Server;
|
||||
|
||||
/// Queues `raw` from `from` to `rcpts`, signed with `sign_domain`'s keys.
|
||||
pub async fn send_signed(
|
||||
server: &Server,
|
||||
from: &str,
|
||||
rcpts: &[String],
|
||||
raw: &[u8],
|
||||
sign_domain: &str,
|
||||
) -> Result<(), String> {
|
||||
let signers = match server.dkim_signers(sign_domain).await {
|
||||
Ok(Some(signers)) => signers,
|
||||
Ok(None) => return Err(format!("{sign_domain} has no DKIM key to sign with.")),
|
||||
Err(err) => {
|
||||
trc::error!(err.details("Failed to retrieve DKIM signers for a report"));
|
||||
return Err(format!("The DKIM keys for {sign_domain} couldn't be read."));
|
||||
}
|
||||
};
|
||||
let mut message = server.new_message(from, MessageSource::Autogenerated, 0);
|
||||
for rcpt in rcpts {
|
||||
message.add_expanded_recipient(rcpt, server).await;
|
||||
}
|
||||
if message
|
||||
.queue(QueueParams::new(raw, 0, server).with_dkim_signers(Some(signers)))
|
||||
.await
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err("The mail queue didn't accept the message.".into())
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod dkim;
|
||||
pub mod dmarc;
|
||||
pub mod 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
|
||||
|
||||
Reference in new issue
Block a user