Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/memory/ingest/canonicalize/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Utc::now()
}

/// One email in a thread.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EmailMessage {
Expand All @@ -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"
)]
Expand Down
51 changes: 51 additions & 0 deletions src/memory/ingest/canonicalize/email_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}