diff --git a/src/memory/ingest/canonicalize/email.rs b/src/memory/ingest/canonicalize/email.rs index 4ab2028..2bdcf01 100644 --- a/src/memory/ingest/canonicalize/email.rs +++ b/src/memory/ingest/canonicalize/email.rs @@ -17,6 +17,14 @@ use serde::{Deserialize, Serialize}; use super::{email_clean, normalize_source_ref, CanonicalisedSource}; use crate::memory::chunks::{Metadata, SourceKind}; +/// Returns the current UTC time as a timestamp default. Used by serde +/// `#[serde(default = …)]` so an email payload missing the `sent_at` field +/// doesn't reject the whole thread — it falls back to `now()`. Mirrors the +/// same tolerance on `ChatMessage::timestamp`. +fn chrono_now() -> DateTime { + Utc::now() +} + /// One email in a thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct EmailMessage { @@ -32,7 +40,11 @@ pub struct EmailMessage { /// Per-message subject; rendered as the `Subject:` header. pub subject: String, /// When the message was sent (epoch-ms integer or RFC 3339 string). + /// When absent from the payload, defaults to `Utc::now()` so that + /// producers that omit the field (version skew / third-party + /// integration) do not cause a hard rejection of the whole thread. #[serde( + default = "chrono_now", serialize_with = "chrono::serde::ts_milliseconds::serialize", deserialize_with = "super::deserialize_flexible_timestamp" )] diff --git a/src/memory/ingest/canonicalize/email_tests.rs b/src/memory/ingest/canonicalize/email_tests.rs index a7daffd..231b54e 100644 --- a/src/memory/ingest/canonicalize/email_tests.rs +++ b/src/memory/ingest/canonicalize/email_tests.rs @@ -204,3 +204,54 @@ fn headers_and_body_cannot_inject_email_boundaries() { .markdown .contains("\\---\nFrom: body-forgery@example.com")); } + +// ── Serde regression tests (sibling of #5169) ─────────────────────────────── + +/// A payload with no `sent_at` field must deserialize gracefully (defaulting +/// to `Utc::now()`) instead of failing with "missing field `sent_at`". This is +/// the email-arm counterpart of the chat-arm fix in #5169. +#[test] +fn missing_sent_at_defaults_to_now() { + let json = r#"{ + "from": "alice@example.com", + "subject": "hi", + "body": "hello" + }"#; + let msg: EmailMessage = serde_json::from_str(json).expect("missing sent_at should not fail"); + let diff = Utc::now().signed_duration_since(msg.sent_at); + assert!( + diff.num_seconds().unsigned_abs() < 5, + "defaulted sent_at should be within ~5s of now, got {diff:?}" + ); +} + +/// A null `sent_at` should also fall back to the default. +#[test] +fn null_sent_at_defaults_to_now() { + let json = r#"{ + "from": "alice@example.com", + "subject": "hi", + "sent_at": null, + "body": "hello" + }"#; + let msg: EmailMessage = serde_json::from_str(json).expect("null sent_at should not fail"); + let diff = Utc::now().signed_duration_since(msg.sent_at); + assert!( + diff.num_seconds().unsigned_abs() < 5, + "defaulted sent_at should be within ~5s of now, got {diff:?}" + ); +} + +/// Explicit epoch-ms `sent_at` still parses exactly (guards against the default +/// masking a real value). +#[test] +fn explicit_sent_at_still_parses() { + let json = r#"{ + "from": "alice@example.com", + "subject": "hi", + "sent_at": 1700000000000, + "body": "hello" + }"#; + let msg: EmailMessage = serde_json::from_str(json).expect("epoch-ms sent_at should parse"); + assert_eq!(msg.sent_at.timestamp_millis(), 1_700_000_000_000); +}