Scheduled reports: a feature byte of their own #162
No files matched your search
@@ -11,12 +11,17 @@
|
||||
//! 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:
|
||||
//! with `G`, then one byte for the kind:
|
||||
//!
|
||||
//! - `r` + report id (u64): a report, as JSON.
|
||||
//! - `s`: the settings, as JSON.
|
||||
//!
|
||||
//! Numbers are big-endian.
|
||||
//!
|
||||
//! 2026.10.6.2 kept them under `S`, a byte scale-out storage (`Sb`, `Sm`,
|
||||
//! `Sl`, replica markers `Sr` + index) and the spam-rules updater (`Sr`,
|
||||
//! `Sf`, `Sn`) already use. `ensure_digest` moves them once, deleting only
|
||||
//! the exact keys it moved.
|
||||
|
||||
use chrono::{Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
@@ -27,7 +32,9 @@ use store::{
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const FEATURE: u8 = b'S';
|
||||
const FEATURE: u8 = b'G';
|
||||
/// Where 2026.10.6.2 kept them; see the module notes.
|
||||
const LEGACY_FEATURE: u8 = b'S';
|
||||
const KIND_REPORT: u8 = b'r';
|
||||
const KIND_SETTINGS: u8 = b's';
|
||||
|
||||
@@ -388,8 +395,12 @@ impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
|
||||
}
|
||||
|
||||
fn class(kind: u8, id: Option<u64>) -> ValueClass {
|
||||
class_in(FEATURE, kind, id)
|
||||
}
|
||||
|
||||
fn class_in(feature: u8, kind: u8, id: Option<u64>) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(10);
|
||||
key.push(FEATURE);
|
||||
key.push(feature);
|
||||
key.push(kind);
|
||||
if let Some(id) = id {
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
@@ -461,8 +472,66 @@ pub fn next_id(reports: &[Report]) -> u64 {
|
||||
+ 1
|
||||
}
|
||||
|
||||
static MOVED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Moves reports and settings from where 2026.10.6.2 kept them. Only what
|
||||
/// reads as a report or the settings is moved, and only those exact keys
|
||||
/// are deleted, so the spam-rules marker and replica markers stay put.
|
||||
async fn move_legacy(data: &Store) -> trc::Result<()> {
|
||||
let mut found = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey::from(class_in(LEGACY_FEATURE, KIND_REPORT, Some(0))),
|
||||
ValueKey::from(class_in(LEGACY_FEATURE, KIND_REPORT, Some(u64::MAX))),
|
||||
),
|
||||
|_, value| {
|
||||
if let Ok(Json(report)) = Json::<Report>::deserialize(value)
|
||||
&& !report.name.is_empty()
|
||||
{
|
||||
found.push(report);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let legacy_settings = data
|
||||
.get_value::<Json<Settings>>(ValueKey::from(class_in(
|
||||
LEGACY_FEATURE,
|
||||
KIND_SETTINGS,
|
||||
None,
|
||||
)))
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
if found.is_empty() && legacy_settings.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut batch = BatchBuilder::new();
|
||||
for report in &found {
|
||||
if self::report(data, report.id).await?.is_none() {
|
||||
batch.set(
|
||||
class(KIND_REPORT, Some(report.id)),
|
||||
Json(report).serialize()?,
|
||||
);
|
||||
}
|
||||
batch.clear(class_in(LEGACY_FEATURE, KIND_REPORT, Some(report.id)));
|
||||
}
|
||||
if let Some(Json(settings)) = legacy_settings {
|
||||
batch.set(class(KIND_SETTINGS, None), Json(&settings).serialize()?);
|
||||
batch.clear(class_in(LEGACY_FEATURE, KIND_SETTINGS, None));
|
||||
}
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every server has the digest (RP-21); written the first time it's missed.
|
||||
pub async fn ensure_digest(data: &Store, now: u64) -> trc::Result<()> {
|
||||
if !MOVED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
move_legacy(data).await?;
|
||||
MOVED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
if report(data, DIGEST_ID).await?.is_none() {
|
||||
put_report(data, &Report::digest(now)).await?;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ use registry::schema::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::{
|
||||
SUBSPACE_INBUXA, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
@@ -53,6 +57,80 @@ pub async fn test(test: &mut TestServer) {
|
||||
let admin = test.account("[email protected]");
|
||||
let me = "[email protected]";
|
||||
|
||||
// --- Moved from where 2026.10.6.2 kept them (`S`, shared with the spam
|
||||
// rules marker and replica markers), touching nothing else ---------------
|
||||
let any = |key: Vec<u8>| {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
};
|
||||
let legacy_digest = [b"Sr".as_slice(), &1u64.to_be_bytes()].concat();
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
any(legacy_digest.clone()),
|
||||
serde_json::to_vec(&json!({
|
||||
"id": 1, "name": "Weekly digest (moved)",
|
||||
"enabled": true,
|
||||
"builtIn": true,
|
||||
"sections": ["mailFlow", "queue", "spoofing", "tlsFailures", "deliverability",
|
||||
"security", "storage", "certificates"],
|
||||
"createdAt": 1790000000,
|
||||
"lastDue": 1790000000
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
batch.set(
|
||||
any(b"Ss".to_vec()),
|
||||
serde_json::to_vec(&json!({"fromName": "Moved sender"})).unwrap(),
|
||||
);
|
||||
batch.set(any(b"Sr".to_vec()), b"3.0.2+2".to_vec());
|
||||
test.server.store().write(batch.build_all()).await.unwrap();
|
||||
let list = reports(admin).await;
|
||||
assert!(
|
||||
list.iter().any(|r| r["name"] == "Weekly digest (moved)"),
|
||||
"the digest wasn't moved: {list:?}"
|
||||
);
|
||||
let (_, response) = call(
|
||||
admin,
|
||||
"inbuxa:ScheduledReportSettings/get",
|
||||
json!({"ids": null}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["list"][0]["fromName"], "Moved sender",
|
||||
"{response}"
|
||||
);
|
||||
let raw = |key: Vec<u8>| {
|
||||
let store = test.server.store().clone();
|
||||
async move {
|
||||
store
|
||||
.get_value::<String>(ValueKey::from(any(key)))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
raw(legacy_digest).await.is_none(),
|
||||
"the old digest key is still there"
|
||||
);
|
||||
assert!(
|
||||
raw(b"Ss".to_vec()).await.is_none(),
|
||||
"the old settings key is still there"
|
||||
);
|
||||
assert_eq!(
|
||||
raw(b"Sr".to_vec()).await.as_deref(),
|
||||
Some("3.0.2+2"),
|
||||
"the spam rules marker was touched"
|
||||
);
|
||||
// Back to the default sender for what follows
|
||||
call(
|
||||
admin,
|
||||
"inbuxa:ScheduledReportSettings/set",
|
||||
json!({"update": {"singleton": {"fromName": ""}}}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// --- The weekly digest every server has (RP-21) ------------------------
|
||||
let list = reports(admin).await;
|
||||
let digest = list
|
||||
|
||||
Reference in new issue
Block a user