Scheduled reports: a feature byte of their own #162

Merged
jcoffey-dev merged 1 commits from fix/scheduled-reports-own-prefix into main 2026-10-07 06:14:36 +00:00
2 changed files with 150 additions and 3 deletions

No files matched your search

+72 -3
View File
@@ -11,12 +11,17 @@
//! and mails it. The weekly digest is a built-in report (RP-21). //! 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 //! 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. //! - `r` + report id (u64): a report, as JSON.
//! - `s`: the settings, as JSON. //! - `s`: the settings, as JSON.
//! //!
//! Numbers are big-endian. //! 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::{Datelike, Duration, LocalResult, NaiveDate, TimeZone, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
@@ -27,7 +32,9 @@ use store::{
}; };
use trc::AddContext; 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_REPORT: u8 = b'r';
const KIND_SETTINGS: u8 = b's'; 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 { 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); let mut key = Vec::with_capacity(10);
key.push(FEATURE); key.push(feature);
key.push(kind); key.push(kind);
if let Some(id) = id { if let Some(id) = id {
key.extend_from_slice(&id.to_be_bytes()); key.extend_from_slice(&id.to_be_bytes());
@@ -461,8 +472,66 @@ pub fn next_id(reports: &[Report]) -> u64 {
+ 1 + 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. /// 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<()> { 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() { if report(data, DIGEST_ID).await?.is_none() {
put_report(data, &Report::digest(now)).await?; put_report(data, &Report::digest(now)).await?;
} }
+78
View File
@@ -18,6 +18,10 @@ use registry::schema::{
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use store::{
SUBSPACE_INBUXA, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
const USING: &[&str] = &[ const USING: &[&str] = &[
"urn:ietf:params:jmap:core", "urn:ietf:params:jmap:core",
@@ -53,6 +57,80 @@ pub async fn test(test: &mut TestServer) {
let admin = test.account("[email protected]"); let admin = test.account("[email protected]");
let me = "[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) ------------------------ // --- The weekly digest every server has (RP-21) ------------------------
let list = reports(admin).await; let list = reports(admin).await;
let digest = list let digest = list