feat(proto): unify time fields on Timestamp/Duration #348

Merged
KofTwentyTwo merged 1 commits from story/ws-a2-timestamps into dev 2026-07-31 21:00:49 +00:00
14 changed files with 440 additions and 114 deletions

No files matched your search

Generated
+1
View File
@@ -2596,6 +2596,7 @@ version = "0.1.0"
dependencies = [
"bytes",
"prost",
"prost-types",
"protoc-bin-vendored",
"thiserror 1.0.69",
"tokio",
+1
View File
@@ -81,6 +81,7 @@ zip = { version = "8.6", default-features = false, features = ["deflate"] }
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"
prost-types = "0.14"
tonic-prost-build = "0.14"
protoc-bin-vendored = "3.2"
subtle = "2.6"
+113 -37
View File
@@ -53,6 +53,48 @@ fn parse_account_protocol(protocol: &str) -> Result<nuncio_proto::v1::AccountPro
}
}
/// Renders a wire-format `google.protobuf.Timestamp` as an RFC 3339 UTC
/// string for human-readable and `--json` output. `nanos` outside
/// `0..1_000_000_000` (never produced by the daemon's own mappers) and an
/// absent field both render as `"unknown"` rather than fabricating a time.
fn format_timestamp(ts: &Option<nuncio_proto::time::Timestamp>) -> String {
ts.as_ref()
.and_then(|ts| {
u32::try_from(ts.nanos)
.ok()
.map(|nanos| (ts.seconds, nanos))
})
.and_then(|(seconds, nanos)| chrono::DateTime::from_timestamp(seconds, nanos))
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| "unknown".to_string())
}
/// Extracts the exact unix-nanosecond instant a wire-format `Timestamp`
/// carries, for callers that need the audit ledger's full sub-second
/// precision rather than the RFC 3339 rendering.
fn timestamp_unix_nanos(ts: &Option<nuncio_proto::time::Timestamp>) -> i64 {
ts.as_ref()
.map(nuncio_proto::time::timestamp_to_unix_nanos)
.unwrap_or(0)
}
/// Renders a wire-format `google.protobuf.Duration` as whole seconds for
/// human-readable and `--json` output (e.g. `AccountConfig.sync_interval`).
fn duration_secs(d: &Option<nuncio_proto::time::Duration>) -> u64 {
d.as_ref()
.map(nuncio_proto::time::duration_to_secs)
.unwrap_or(0)
}
/// Renders a wire-format `google.protobuf.Duration` as whole microseconds
/// for human-readable and `--json` output (e.g.
/// `PreviewRuleResponse.execution_time`).
fn duration_micros(d: &Option<nuncio_proto::time::Duration>) -> u64 {
d.as_ref()
.map(nuncio_proto::time::duration_to_micros)
.unwrap_or(0)
}
/// Renders a `nuncio.v1.Message` (as returned by the daemon's `Mail` gRPC
/// service) into the JSON shape used by `mail list`/`mail read`'s
/// `--json` output.
@@ -64,7 +106,7 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val
"subject": message.subject,
"sender": message.sender,
"recipient": message.recipient,
"received_at": message.received_at,
"received_at": format_timestamp(&message.received_at),
"read": message.read,
"body_plain": message.body_plain,
"body_html": message.body_html,
@@ -80,8 +122,8 @@ fn calendar_event_proto_to_json(event: &nuncio_proto::v1::CalendarEvent) -> serd
"account_id": event.account_id,
"calendar_id": event.calendar_id,
"summary": event.summary,
"start_time": event.start_time,
"end_time": event.end_time,
"start_time": format_timestamp(&event.start_time),
"end_time": format_timestamp(&event.end_time),
"rrule": event.rrule,
"location": event.location,
})
@@ -150,7 +192,8 @@ fn map_export_format_to_proto(format: nuncio_core::ExportFormat) -> nuncio_proto
fn audit_record_proto_to_json(record: &nuncio_proto::v1::AuditRecord) -> serde_json::Value {
json!({
"sequence": record.sequence,
"timestamp_ns": record.timestamp_ns,
"timestamp": format_timestamp(&record.timestamp),
"timestamp_ns": timestamp_unix_nanos(&record.timestamp),
"actor": record.actor,
"action": record.action,
"data_hash": record.data_hash,
@@ -171,8 +214,8 @@ fn filter_rule_proto_to_json(rule: &nuncio_proto::v1::FilterRule) -> serde_json:
"enabled": rule.enabled,
"nsql_text": rule.nsql_text,
"actions": rule.actions,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
"created_at": format_timestamp(&rule.created_at),
"updated_at": format_timestamp(&rule.updated_at),
})
}
@@ -184,7 +227,7 @@ fn filter_execution_log_proto_to_json(
"rule_id": log.rule_id,
"message_id": log.message_id,
"action_taken": log.action_taken,
"matched_at": log.matched_at,
"matched_at": format_timestamp(&log.matched_at),
"prev_hash": log.prev_hash,
"hash": log.hash,
})
@@ -882,7 +925,10 @@ impl HeadlessRunner {
} else {
format!(
"Message {}: Subject: '{}', From: {}, Date: {}",
msg.id, msg.subject, msg.sender, msg.received_at
msg.id,
msg.subject,
msg.sender,
format_timestamp(&msg.received_at)
)
}
}
@@ -1066,13 +1112,15 @@ impl HeadlessRunner {
} else if records.is_empty() {
"No audit records recorded.".to_string()
} else {
let mut out = String::from(
"SEQ ACTOR ACTION TIMESTAMP_NS\n",
);
let mut out =
String::from("SEQ ACTOR ACTION TIMESTAMP\n");
for r in records {
out.push_str(&format!(
"{:<4} {:<20} {:<20} {}\n",
r.sequence, r.actor, r.action, r.timestamp_ns
r.sequence,
r.actor,
r.action,
format_timestamp(&r.timestamp)
));
}
out
@@ -1348,13 +1396,15 @@ impl HeadlessRunner {
"matched_rule_id": preview.matched_rule_id,
"matched_rule_name": preview.matched_rule_name,
"actions_evaluated": preview.actions_evaluated,
"execution_time_us": preview.execution_time_us,
"execution_time_us": duration_micros(&preview.execution_time),
"condition_traces": preview.condition_traces,
}))
} else {
format!(
"Dry-run evaluation result: matched={}, actions={:?}, elapsed={}us",
preview.matched, preview.actions_evaluated, preview.execution_time_us
preview.matched,
preview.actions_evaluated,
duration_micros(&preview.execution_time)
)
}
}
@@ -1545,7 +1595,11 @@ impl HeadlessRunner {
for l in logs {
out.push_str(&format!(
"{:<4} {:<10} {:<10} {:<12} {}\n",
l.id, l.rule_id, l.message_id, l.action_taken, l.matched_at
l.id,
l.rule_id,
l.message_id,
l.action_taken,
format_timestamp(&l.matched_at)
));
}
out
@@ -1733,7 +1787,7 @@ impl HeadlessRunner {
imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(),
smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(),
keyring_secret_key: keyring_key.clone(),
sync_interval_secs: 300,
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
smtp_host: if is_caldav {
String::new()
} else {
@@ -1837,7 +1891,7 @@ impl HeadlessRunner {
"imap_tls_mode": a.imap_tls_mode().as_str_name(),
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
"keyring_secret_key": a.keyring_secret_key,
"sync_interval_secs": a.sync_interval_secs,
"sync_interval_secs": duration_secs(&a.sync_interval),
"collection_url": a.collection_url,
})
})
@@ -1932,7 +1986,7 @@ impl HeadlessRunner {
"imap_tls_mode": account.imap_tls_mode().as_str_name(),
"smtp_tls_mode": account.smtp_tls_mode().as_str_name(),
"keyring_secret_key": account.keyring_secret_key,
"sync_interval_secs": account.sync_interval_secs,
"sync_interval_secs": duration_secs(&account.sync_interval),
}))
} else {
format!(
@@ -2190,8 +2244,8 @@ impl HeadlessRunner {
.list_events(nuncio_proto::v1::ListEventsRequest {
account_id: account.to_string(),
calendar_id: calendar.to_string(),
start_window: start,
end_window: end,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(start)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(end)),
})
.await
{
@@ -2241,8 +2295,8 @@ impl HeadlessRunner {
.sync(nuncio_proto::v1::CalendarSyncRequest {
account_id: account.to_string(),
calendar_id: calendar.to_string(),
start_window: start,
end_window: end,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(start)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(end)),
})
.await
{
@@ -2933,7 +2987,7 @@ mod tests {
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: "nuncio/acct-stub-1".to_string(),
sync_interval_secs: 300,
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
collection_url: String::new(),
@@ -3145,7 +3199,7 @@ mod tests {
subject: "Stub Subject".to_string(),
sender: "alice@nuncio.mx".to_string(),
recipient: "bob@nuncio.mx".to_string(),
received_at: 1_700_000_000,
received_at: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)),
read: false,
body_plain: Some("Stub body text".to_string()),
body_html: None,
@@ -3598,8 +3652,8 @@ mod tests {
account_id: "acct-stub-1".to_string(),
calendar_id: "cal-stub".to_string(),
summary: "Stub Meeting".to_string(),
start_time: 1_700_000_000,
end_time: 1_700_003_600,
start_time: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)),
end_time: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_003_600)),
rrule: None,
location: Some("Room 1".to_string()),
}
@@ -3707,8 +3761,14 @@ mod tests {
.expect("stub daemon received a list_events request");
assert_eq!(recorded_list.account_id, "acct-stub-1");
assert_eq!(recorded_list.calendar_id, "cal-stub");
assert_eq!(recorded_list.start_window, 0);
assert_eq!(recorded_list.end_window, i64::MAX);
assert_eq!(
recorded_list.start_window,
Some(nuncio_proto::time::timestamp_from_unix_secs(0))
);
assert_eq!(
recorded_list.end_window,
Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX))
);
// `cal sync`: proves the CLI is a real gRPC client of
// `Calendar/Sync` and reports the daemon's real `synced_count`,
@@ -4024,8 +4084,12 @@ mod tests {
enabled: true,
nsql_text: req.nsql,
actions: vec!["MARK READ".to_string()],
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
created_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_000,
)),
updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_000,
)),
}),
}))
}
@@ -4043,8 +4107,12 @@ mod tests {
enabled: true,
nsql_text: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(),
actions: vec!["MARK READ".to_string()],
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
created_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_000,
)),
updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_000,
)),
}],
}))
}
@@ -4086,7 +4154,7 @@ mod tests {
matched_rule_id: Some("rule-stub-1".to_string()),
matched_rule_name: Some("Stub Rule".to_string()),
actions_evaluated: vec!["MARK READ".to_string()],
execution_time_us: 42,
execution_time: Some(nuncio_proto::time::duration_from_micros(42)),
condition_traces: vec!["Rule 'Stub Rule': MATCH".to_string()],
}))
}
@@ -4111,8 +4179,12 @@ mod tests {
.nsql
.unwrap_or_else(|| "WHERE subject CONTAINS 'Urgent'".to_string()),
actions: vec!["MARK READ".to_string()],
created_at: 1_700_000_000,
updated_at: 1_700_000_001,
created_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_000,
)),
updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_001,
)),
}),
}))
}
@@ -4153,7 +4225,9 @@ mod tests {
rule_id: "rule-stub-1".to_string(),
message_id: "msg-stub-1".to_string(),
action_taken: "MARK READ".to_string(),
matched_at: 1_700_000_002,
matched_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
1_700_000_002,
)),
prev_hash: "prev-hash".to_string(),
hash: "hash".to_string(),
}],
@@ -4640,7 +4714,9 @@ mod tests {
Ok(tonic::Response::new(ListRecordsResponse {
records: vec![AuditRecord {
sequence: 1,
timestamp_ns: 1_700_000_000_000_000_000,
timestamp: Some(nuncio_proto::time::timestamp_from_unix_nanos(
1_700_000_000_000_000_000,
)),
actor: "system.test".to_string(),
action: "data.export".to_string(),
data_hash: "deadbeef".to_string(),
+1
View File
@@ -13,6 +13,7 @@ workspace = true
tonic.workspace = true
tonic-prost.workspace = true
prost.workspace = true
prost-types.workspace = true
bytes.workspace = true
thiserror.workspace = true
Binary file not shown.
@@ -2,6 +2,9 @@ syntax = "proto3";
package nuncio.v1;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
// ErrorReason is the stable, machine-readable taxonomy of failure conditions
// the daemon reports to clients. It is carried inside an `ErrorInfo` in a
// failed RPC's gRPC status-details trailer so a client can branch on a
@@ -272,8 +275,8 @@ message AccountConfig {
TlsMode smtp_tls_mode = 9;
// Keyring entry key used to retrieve OS vault credentials.
string keyring_secret_key = 10;
// Background sync polling interval in seconds (minimum 10s).
uint64 sync_interval_secs = 11;
// Background sync polling interval (minimum 10s).
google.protobuf.Duration sync_interval = 11;
// SMTP server hostname or IP address, used for outbound mail. Distinct
// from `server_host` because a mail provider's inbound (IMAP/JMAP) and
// outbound (SMTP) endpoints commonly differ.
@@ -435,8 +438,8 @@ message Message {
string sender = 5;
// Recipient address.
string recipient = 6;
// Unix timestamp of message arrival.
int64 received_at = 7;
// Timestamp of message arrival.
google.protobuf.Timestamp received_at = 7;
// Read/unread flag status.
bool read = 8;
// Plaintext message body (already decrypted by the store).
@@ -663,10 +666,10 @@ message FilterRule {
// Rendered NSQL action clauses (e.g. "MOVE TO 'Archive'"), derived from
// the parsed AST.
repeated string actions = 7;
// Creation timestamp (unix seconds).
int64 created_at = 8;
// Last update timestamp (unix seconds).
int64 updated_at = 9;
// Creation timestamp.
google.protobuf.Timestamp created_at = 8;
// Last update timestamp.
google.protobuf.Timestamp updated_at = 9;
}
// CreateRuleRequest carries a new rule's display name, full NSQL source
@@ -750,8 +753,8 @@ message PreviewRuleResponse {
optional string matched_rule_name = 4;
// NSQL-rendered actions that would be executed on a real match.
repeated string actions_evaluated = 5;
// Evaluation duration in microseconds.
uint64 execution_time_us = 6;
// Evaluation duration.
google.protobuf.Duration execution_time = 6;
// Human-readable step-by-step match traces.
repeated string condition_traces = 7;
}
@@ -825,8 +828,8 @@ message FilterExecutionLog {
string message_id = 3;
// Action description string.
string action_taken = 4;
// Timestamp of execution (unix seconds).
int64 matched_at = 5;
// Timestamp of execution.
google.protobuf.Timestamp matched_at = 5;
// Hash of the preceding log record in the hash-chain ledger.
string prev_hash = 6;
// HMAC-SHA256 hash of this record.
@@ -973,10 +976,10 @@ message CalendarEvent {
string calendar_id = 3;
// Event title or summary.
string summary = 4;
// Unix timestamp for event start.
int64 start_time = 5;
// Unix timestamp for event end.
int64 end_time = 6;
// Timestamp for event start.
google.protobuf.Timestamp start_time = 5;
// Timestamp for event end.
google.protobuf.Timestamp end_time = 6;
// Recurrence rule string (RFC 5545 RRULE format).
optional string rrule = 7;
// Location text.
@@ -989,8 +992,8 @@ message CalendarEvent {
message CalendarSyncRequest {
string account_id = 1;
string calendar_id = 2;
int64 start_window = 3;
int64 end_window = 4;
google.protobuf.Timestamp start_window = 3;
google.protobuf.Timestamp end_window = 4;
}
// CalendarSyncResponse reports how many events were synchronized.
@@ -1002,8 +1005,8 @@ message CalendarSyncResponse {
message ListEventsRequest {
string account_id = 1;
string calendar_id = 2;
int64 start_window = 3;
int64 end_window = 4;
google.protobuf.Timestamp start_window = 3;
google.protobuf.Timestamp end_window = 4;
}
// ListEventsResponse.
@@ -1197,8 +1200,9 @@ service Audit {
message AuditRecord {
// Monotonically increasing sequence index.
uint64 sequence = 1;
// UTC timestamp in nanoseconds since Unix epoch.
int64 timestamp_ns = 2;
// UTC timestamp, preserving the audit ledger's nanosecond precision in
// the `nanos` field.
google.protobuf.Timestamp timestamp = 2;
// Subsystem or actor that performed the audited action.
string actor = 3;
// Action identifier (e.g. "data.export").
+1
View File
@@ -23,6 +23,7 @@
pub mod addr;
pub mod client;
pub mod errors;
pub mod time;
pub use addr::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR};
+138
View File
@@ -0,0 +1,138 @@
//! Conversions between the store's native integer instants/durations and the
//! well-known `google.protobuf.Timestamp`/`google.protobuf.Duration` wire
//! types used at the `nuncio.v1` proto boundary.
//!
//! The daemon's core/store models keep unix-seconds `i64`/`u64` instants and
//! plain scalar durations; these helpers are the single place that maps them
//! to/from the well-known types so every RPC mapper does it the same way.
//! Re-exporting `Timestamp`/`Duration` here lets callers name the wire types
//! without depending on `prost-types` directly.
pub use prost_types::{Duration, Timestamp};
/// Converts a unix-seconds instant into a `Timestamp` with a zero `nanos`
/// component. Use this for store fields that only ever carry second
/// granularity (e.g. message/event/rule timestamps).
#[must_use]
pub fn timestamp_from_unix_secs(unix_secs: i64) -> Timestamp {
Timestamp {
seconds: unix_secs,
nanos: 0,
}
}
/// Extracts the unix-seconds instant from a `Timestamp`, truncating any
/// sub-second `nanos` component. Use this for fields mapped via
/// [`timestamp_from_unix_secs`], where `nanos` is always zero.
#[must_use]
pub fn timestamp_to_unix_secs(ts: &Timestamp) -> i64 {
ts.seconds
}
/// Converts a unix-nanoseconds instant into a `Timestamp`, splitting it
/// losslessly into whole seconds plus a sub-second `nanos` remainder. Use
/// this for fields that carry sub-second precision (e.g. the WORM audit
/// ledger's `timestamp_ns`).
#[must_use]
pub fn timestamp_from_unix_nanos(unix_nanos: i64) -> Timestamp {
let seconds = unix_nanos.div_euclid(1_000_000_000);
let nanos = unix_nanos.rem_euclid(1_000_000_000);
Timestamp {
seconds,
nanos: nanos as i32,
}
}
/// Recovers the exact unix-nanoseconds instant a `Timestamp` was built from
/// via [`timestamp_from_unix_nanos`].
#[must_use]
pub fn timestamp_to_unix_nanos(ts: &Timestamp) -> i64 {
ts.seconds
.saturating_mul(1_000_000_000)
.saturating_add(i64::from(ts.nanos))
}
/// Converts a whole-second duration into a `Duration` with a zero `nanos`
/// component (e.g. `AccountConfig`'s sync polling interval).
#[must_use]
pub fn duration_from_secs(secs: u64) -> Duration {
Duration {
seconds: secs as i64,
nanos: 0,
}
}
/// Extracts a whole-second duration from a `Duration`, truncating any
/// sub-second `nanos` component. A negative `seconds` (never produced by
/// this crate's own mappers) reads back as zero rather than panicking.
#[must_use]
pub fn duration_to_secs(d: &Duration) -> u64 {
u64::try_from(d.seconds).unwrap_or(0)
}
/// Converts a microsecond duration into a `Duration`, splitting it
/// losslessly into whole seconds plus a sub-second `nanos` remainder (e.g.
/// filter rule preview evaluation timing).
#[must_use]
pub fn duration_from_micros(micros: u64) -> Duration {
let seconds = micros / 1_000_000;
let nanos = (micros % 1_000_000) * 1_000;
Duration {
seconds: seconds as i64,
nanos: nanos as i32,
}
}
/// Recovers the exact microsecond duration a `Duration` was built from via
/// [`duration_from_micros`].
#[must_use]
pub fn duration_to_micros(d: &Duration) -> u64 {
let seconds = u64::try_from(d.seconds).unwrap_or(0);
let nanos = u64::try_from(d.nanos).unwrap_or(0);
seconds
.saturating_mul(1_000_000)
.saturating_add(nanos / 1_000)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seconds_round_trip_through_timestamp() {
let ts = timestamp_from_unix_secs(1_700_000_000);
assert_eq!(ts.nanos, 0);
assert_eq!(timestamp_to_unix_secs(&ts), 1_700_000_000);
}
#[test]
fn nanos_round_trip_through_timestamp() {
let original_ns = 1_700_000_000_123_456_789_i64;
let ts = timestamp_from_unix_nanos(original_ns);
assert_eq!(timestamp_to_unix_nanos(&ts), original_ns);
}
#[test]
fn negative_epoch_nanos_round_trip() {
// Regression: pre-epoch instants must still split into a
// non-negative `nanos` remainder per the Timestamp wire contract.
let original_ns = -1_500_000_000_i64;
let ts = timestamp_from_unix_nanos(original_ns);
assert!(ts.nanos >= 0);
assert_eq!(timestamp_to_unix_nanos(&ts), original_ns);
}
#[test]
fn secs_round_trip_through_duration() {
let d = duration_from_secs(300);
assert_eq!(d.nanos, 0);
assert_eq!(duration_to_secs(&d), 300);
}
#[test]
fn micros_round_trip_through_duration() {
let original_us = 1_234_567_u64;
let d = duration_from_micros(original_us);
assert_eq!(duration_to_micros(&d), original_us);
}
}
+140 -36
View File
@@ -285,7 +285,9 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon
imap_tls_mode: map_tls_mode_to_proto(config.imap_tls_mode).into(),
smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(),
keyring_secret_key: config.keyring_secret_key,
sync_interval_secs: config.sync_interval_secs,
sync_interval: Some(nuncio_proto::time::duration_from_secs(
config.sync_interval_secs,
)),
smtp_host: config.smtp_host,
smtp_port: u32::from(config.smtp_port),
collection_url: config.collection_url,
@@ -305,6 +307,9 @@ fn map_account_config_from_proto(
.map_err(|_| Status::invalid_argument("server_port must be in range 1..=65535"))?;
let smtp_port = u16::try_from(config.smtp_port)
.map_err(|_| Status::invalid_argument("smtp_port must be in range 1..=65535"))?;
let sync_interval = config
.sync_interval
.ok_or_else(|| Status::invalid_argument("sync_interval is required"))?;
Ok(nuncio_core::AccountConfig {
id: config.id,
@@ -319,7 +324,7 @@ fn map_account_config_from_proto(
imap_tls_mode,
smtp_tls_mode,
keyring_secret_key: config.keyring_secret_key,
sync_interval_secs: config.sync_interval_secs,
sync_interval_secs: nuncio_proto::time::duration_to_secs(&sync_interval),
collection_url: config.collection_url,
})
}
@@ -777,7 +782,9 @@ fn map_email_to_proto(email: nuncio_core::model::Email) -> MessageProto {
subject: email.subject,
sender: email.sender,
recipient: email.recipient,
received_at: email.received_at,
received_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
email.received_at,
)),
read: email.read,
body_plain: email.body_plain,
body_html: email.body_html,
@@ -812,8 +819,10 @@ fn map_calendar_event_to_proto(event: nuncio_core::model::CalendarEvent) -> Cale
account_id: event.account_id,
calendar_id: event.calendar_id,
summary: event.summary,
start_time: event.start_time,
end_time: event.end_time,
start_time: Some(nuncio_proto::time::timestamp_from_unix_secs(
event.start_time,
)),
end_time: Some(nuncio_proto::time::timestamp_from_unix_secs(event.end_time)),
rrule: event.rrule,
location: event.location,
}
@@ -858,6 +867,17 @@ fn map_contact_to_proto(contact: nuncio_contacts::Contact) -> ContactProto {
}
}
/// Extracts a required window bound (`start_window`/`end_window`) from its
/// wire-format `Timestamp`, rejecting an absent field with
/// `Status::invalid_argument` rather than silently defaulting to the epoch.
fn require_window_bound(
ts: Option<nuncio_proto::time::Timestamp>,
field: &str,
) -> Result<i64, Status> {
let ts = ts.ok_or_else(|| Status::invalid_argument(format!("{field} is required")))?;
Ok(nuncio_proto::time::timestamp_to_unix_secs(&ts))
}
/// Combines an account's in-window events with its recurring masters into the final event
/// list for a `ListEvents` query window, expanding each recurring master into its real
/// occurrences via [`nuncio_cal::RecurrenceEngine::expand_occurrences`] rather than trusting
@@ -956,6 +976,8 @@ impl CalendarGrpcService {
async fn sync_configured_caldav_accounts(
&self,
req: &CalendarSyncRequest,
start_window: i64,
end_window: i64,
) -> Result<usize, Status> {
let caldav_accounts: Vec<nuncio_core::AccountConfig> = self
.db
@@ -1004,8 +1026,8 @@ impl CalendarGrpcService {
&account,
&password,
&req.calendar_id,
req.start_window,
req.end_window,
start_window,
end_window,
)
.await
.map_err(|e| {
@@ -1027,18 +1049,23 @@ impl Calendar for CalendarGrpcService {
request: Request<CalendarSyncRequest>,
) -> Result<Response<CalendarSyncResponse>, Status> {
let req = request.into_inner();
let start_window = require_window_bound(req.start_window, "start_window")?;
let end_window = require_window_bound(req.end_window, "end_window")?;
let synced = match &self.overrides.calendar_backend {
Some(backend) => crate::calendar_sync::sync_with_backend(
&self.db,
backend.as_ref(),
&req.calendar_id,
req.start_window,
req.end_window,
start_window,
end_window,
)
.await
.map_err(|e| Status::internal(format!("calendar sync failed: {e}")))?,
None => self.sync_configured_caldav_accounts(&req).await?,
None => {
self.sync_configured_caldav_accounts(&req, start_window, end_window)
.await?
}
};
Ok(Response::new(CalendarSyncResponse {
@@ -1054,10 +1081,12 @@ impl Calendar for CalendarGrpcService {
if req.account_id.is_empty() {
return Err(Status::invalid_argument("account_id is required"));
}
let start_window = require_window_bound(req.start_window, "start_window")?;
let end_window = require_window_bound(req.end_window, "end_window")?;
let all_in_window = self
.db
.list_calendar_events(&req.account_id, req.start_window, req.end_window)
.list_calendar_events(&req.account_id, start_window, end_window)
.await
.map_err(|e| Status::internal(format!("failed to list calendar events: {e}")))?;
@@ -1068,7 +1097,7 @@ impl Calendar for CalendarGrpcService {
.map_err(|e| Status::internal(format!("failed to list recurring events: {e}")))?;
let mut events =
expand_events_for_window(all_in_window, recurring, req.start_window, req.end_window);
expand_events_for_window(all_in_window, recurring, start_window, end_window);
events.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id);
@@ -1574,8 +1603,12 @@ fn map_filter_rule_to_proto(rule: nuncio_filter::FilterRule) -> FilterRuleProto
enabled: rule.enabled,
nsql_text: rule.nsql_text,
actions: rule.actions.iter().map(|a| a.to_nsql()).collect(),
created_at: rule.created_at,
updated_at: rule.updated_at,
created_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
rule.created_at,
)),
updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs(
rule.updated_at,
)),
}
}
@@ -1589,7 +1622,7 @@ fn map_filter_execution_log_to_proto(
rule_id: log.rule_id,
message_id: log.message_id,
action_taken: log.action_taken,
matched_at: log.matched_at,
matched_at: Some(nuncio_proto::time::timestamp_from_unix_secs(log.matched_at)),
prev_hash: log.prev_hash,
hash: log.hash,
}
@@ -1608,7 +1641,9 @@ fn map_preview_result_to_proto(preview: nuncio_filter::FilterPreviewResult) -> P
.iter()
.map(|a| a.to_nsql())
.collect(),
execution_time_us: preview.execution_time_us,
execution_time: Some(nuncio_proto::time::duration_from_micros(
preview.execution_time_us,
)),
condition_traces: preview.condition_traces,
}
}
@@ -2152,7 +2187,9 @@ const DEFAULT_LIST_RECORDS_LIMIT: u32 = 100;
fn map_audit_record_to_proto(record: nuncio_core::WormAuditRecord) -> AuditRecordProto {
AuditRecordProto {
sequence: record.sequence,
timestamp_ns: record.timestamp_ns,
timestamp: Some(nuncio_proto::time::timestamp_from_unix_nanos(
record.timestamp_ns,
)),
actor: record.actor,
action: record.action,
data_hash: record.data_hash,
@@ -2575,6 +2612,73 @@ mod tests {
use nuncio_proto::v1::system_client::SystemClient;
use tonic::Code;
/// Proves a stored second-granularity instant survives the proto
/// boundary exactly: `map_email_to_proto`'s `received_at` decodes back
/// to the identical unix-seconds value via `timestamp_to_unix_secs`.
#[test]
fn map_email_to_proto_round_trips_received_at_exactly() {
let original_secs = 1_700_000_123_i64;
let email = nuncio_core::model::Email {
id: "msg-1".to_string(),
account_id: "acct-1".to_string(),
folder_id: "inbox".to_string(),
subject: "s".to_string(),
sender: "a@b.com".to_string(),
recipient: "c@d.com".to_string(),
received_at: original_secs,
read: false,
body_plain: None,
body_html: None,
attachments: Vec::new(),
remote_id: "remote-1".to_string(),
uid_validity: "1".to_string(),
};
let proto = map_email_to_proto(email);
let ts = proto.received_at.expect("received_at is always populated");
assert_eq!(
nuncio_proto::time::timestamp_to_unix_secs(&ts),
original_secs
);
}
/// Proves the WORM audit ledger's sub-second precision survives the
/// proto boundary losslessly: `map_audit_record_to_proto`'s `timestamp`
/// decodes back to the exact original `timestamp_ns`, not just the
/// truncated seconds component.
#[test]
fn map_audit_record_to_proto_preserves_nanosecond_precision() {
let original_ns = 1_700_000_123_987_654_321_i64;
let record = nuncio_core::WormAuditRecord {
sequence: 1,
timestamp_ns: original_ns,
actor: "system".to_string(),
action: "test.action".to_string(),
data_hash: "hash".to_string(),
previous_block_hash: "GENESIS".to_string(),
record_hmac: "mac".to_string(),
};
let proto = map_audit_record_to_proto(record);
let ts = proto.timestamp.expect("timestamp is always populated");
assert_eq!(
nuncio_proto::time::timestamp_to_unix_nanos(&ts),
original_ns
);
}
/// Proves an account's sync interval survives the proto `Duration`
/// boundary in both directions: to-proto then from-proto reconstructs
/// the exact original whole-second value.
#[test]
fn account_sync_interval_round_trips_through_duration() {
let mut config = sample_account_config_proto("acct-rt", "nuncio/acct-rt");
config.sync_interval = Some(nuncio_proto::time::duration_from_secs(600));
let core_config = map_account_config_from_proto(config).expect("valid config maps back");
assert_eq!(core_config.sync_interval_secs, 600);
}
/// Spawns a test server backed by a fresh ephemeral database, a fresh
/// empty [`FilterEngine`], and a fresh [`SecretManager::mock`] vault --
/// the right default for every test that only exercises
@@ -3137,7 +3241,7 @@ mod tests {
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: keyring_secret_key.to_string(),
sync_interval_secs: 60,
sync_interval: Some(nuncio_proto::time::duration_from_secs(60)),
collection_url: String::new(),
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
@@ -3708,8 +3812,8 @@ mod tests {
.list_events(ListEventsRequest {
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
})
.await
.expect_err("missing bearer token must be rejected");
@@ -3727,8 +3831,8 @@ mod tests {
.sync(CalendarSyncRequest {
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
})
.await
.expect_err("missing bearer token must be rejected");
@@ -5539,8 +5643,8 @@ mod tests {
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-1".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)),
}))
.await
.expect("list_events succeeds")
@@ -5598,8 +5702,8 @@ mod tests {
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-1".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)),
}))
.await
.expect("list_events succeeds")
@@ -5624,8 +5728,8 @@ mod tests {
.list_events(authed_bearer_request(ListEventsRequest {
account_id: String::new(),
calendar_id: "cal-work".to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
}))
.await
.expect_err("empty account_id must be rejected");
@@ -5717,8 +5821,8 @@ mod tests {
.sync(authed_bearer_request(CalendarSyncRequest {
account_id: "acct-cal-no-config".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
}))
.await
.expect_err("sync with no injected backend and no CalDAV config must fail");
@@ -5775,8 +5879,8 @@ mod tests {
.sync(authed_bearer_request(CalendarSyncRequest {
account_id: "acct-cal-injected".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)),
}))
.await
.expect("sync succeeds")
@@ -5788,8 +5892,8 @@ mod tests {
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-injected".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)),
}))
.await
.expect("list_events succeeds")
@@ -6296,8 +6400,8 @@ mod tests {
.list_events(ListEventsRequest {
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
})
.await
.expect_err("Calendar must reject an unauthenticated call");
@@ -86,7 +86,7 @@ fn sample_account_config() -> AccountConfig {
imap_tls_mode: TlsMode::StartTls.into(),
smtp_tls_mode: TlsMode::Plain.into(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval_secs: 60,
sync_interval: Some(nuncio_proto::time::duration_from_secs(60)),
smtp_host: "127.0.0.1".to_string(),
smtp_port: 25,
collection_url: String::new(),
@@ -165,7 +165,7 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() {
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval_secs: 300,
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
smtp_host: String::new(),
smtp_port: 0,
collection_url: collection_url.clone(),
@@ -183,8 +183,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() {
.sync(CalendarSyncRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_700_000_000,
end_window: 1_800_000_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)),
})
.await
.expect("production CalDAV sync succeeds")
@@ -196,8 +196,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() {
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_700_000_000,
end_window: 1_800_000_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)),
})
.await
.expect("list_events succeeds")
@@ -216,8 +216,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() {
.sync(CalendarSyncRequest {
account_id: "acct-does-not-exist".to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_700_000_000,
end_window: 1_800_000_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)),
})
.await
.expect_err("a sync targeting an unconfigured account must fail honestly");
@@ -232,8 +232,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() {
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
})
.await
.expect_err("missing bearer token must be rejected");
+6 -6
View File
@@ -138,8 +138,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() {
.sync(CalendarSyncRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_699_999_000,
end_window: 1_700_020_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_020_000)),
})
.await
.expect("sync succeeds")
@@ -152,8 +152,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() {
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_699_999_000,
end_window: 1_700_020_000,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_020_000)),
})
.await
.expect("list_events succeeds")
@@ -187,8 +187,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() {
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 0,
end_window: i64::MAX,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)),
})
.await
.expect_err("missing bearer token must be rejected");
+1 -1
View File
@@ -65,7 +65,7 @@ fn sample_account_config() -> AccountConfig {
imap_tls_mode: TlsMode::ImplicitTls.into(),
smtp_tls_mode: TlsMode::ImplicitTls.into(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval_secs: 60,
sync_interval: Some(nuncio_proto::time::duration_from_secs(60)),
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
collection_url: String::new(),
@@ -140,8 +140,8 @@ async fn typed_error_reasons_decode_from_status_details_over_the_wire() {
.sync(CalendarSyncRequest {
account_id: String::new(),
calendar_id: "personal".to_string(),
start_window: 0,
end_window: 0,
start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)),
})
.await
.expect_err("syncing with no CalDAV account configured must fail");