diff --git a/crates/features/src/scheduled_reports/mod.rs b/crates/features/src/scheduled_reports/mod.rs index d2a0b2b..e71bd9e 100644 --- a/crates/features/src/scheduled_reports/mod.rs +++ b/crates/features/src/scheduled_reports/mod.rs @@ -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 SerdeDeserialize<'de> + Send + Sync> Deserialize for Json { } fn class(kind: u8, id: Option) -> ValueClass { + class_in(FEATURE, kind, id) +} + +fn class_in(feature: u8, kind: u8, id: Option) -> 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::::deserialize(value) + && !report.name.is_empty() + { + found.push(report); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + let legacy_settings = data + .get_value::>(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?; } diff --git a/tests/src/system/scheduled_reports.rs b/tests/src/system/scheduled_reports.rs index 6c6806c..733583c 100644 --- a/tests/src/system/scheduled_reports.rs +++ b/tests/src/system/scheduled_reports.rs @@ -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("admin@example.com"); let me = "admin@example.com"; + // --- 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| { + 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| { + let store = test.server.store().clone(); + async move { + store + .get_value::(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