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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user