From d666ce61d70af37bf42bae6956bccba1b8c54f64 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:33:34 +0530 Subject: [PATCH] fix(email): add date_local field with host timezone to email reshaper (#3128) (#3143) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Steven Enamakel --- .../OpenhumanLinkModal.notifications.test.tsx | 25 ++++---- .../composio/providers/gmail/post_process.rs | 55 ++++++++++++++++++ .../providers/gmail/post_process_tests.rs | 57 +++++++++++++++++++ 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx index f552298e4..a5b5a4110 100644 --- a/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx +++ b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx @@ -34,12 +34,6 @@ describe('OpenhumanLinkModal notifications test flow', () => { }); } - async function flushAsyncWork() { - await act(async () => { - await Promise.resolve(); - }); - } - it('shows success after permission is granted and native notification send succeeds', async () => { vi.mocked(isTauri).mockReturnValue(true); vi.mocked(ensureNotificationPermission).mockResolvedValue(true); @@ -69,9 +63,10 @@ describe('OpenhumanLinkModal notifications test flow', () => { openNotificationsModal(); fireEvent.click(screen.getByRole('button', { name: 'Send test notification' })); - await flushAsyncWork(); - expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument() + ); expect(showNativeNotification).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Retry test notification' })).toBeInTheDocument(); }); @@ -89,11 +84,12 @@ describe('OpenhumanLinkModal notifications test flow', () => { openNotificationsModal(); fireEvent.click(screen.getByRole('button', { name: 'Send test notification' })); - await flushAsyncWork(); - expect( - screen.getByText(/Couldn't send: notification show failed: test error/i) - ).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByText(/Couldn't send: notification show failed: test error/i) + ).toBeInTheDocument() + ); }); it('retries successfully after user grants permission on a second attempt', async () => { @@ -111,9 +107,10 @@ describe('OpenhumanLinkModal notifications test flow', () => { openNotificationsModal(); fireEvent.click(screen.getByRole('button', { name: 'Send test notification' })); - await flushAsyncWork(); - expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument() + ); fireEvent.click(screen.getByRole('button', { name: 'Retry test notification' })); diff --git a/src/openhuman/memory_sync/composio/providers/gmail/post_process.rs b/src/openhuman/memory_sync/composio/providers/gmail/post_process.rs index 8a59feab9..dfd4067b4 100644 --- a/src/openhuman/memory_sync/composio/providers/gmail/post_process.rs +++ b/src/openhuman/memory_sync/composio/providers/gmail/post_process.rs @@ -315,6 +315,54 @@ fn reshape_fetch_emails(data: &mut Value) { *container = Value::Object(envelope); } +/// Parse an RFC 3339 or RFC 2822 date string into a UTC `DateTime`. +pub(crate) fn parse_email_date(date_str: &str) -> Option> { + date_str + .parse::>() + .or_else(|_| { + chrono::DateTime::parse_from_rfc2822(date_str).map(|d| d.with_timezone(&chrono::Utc)) + }) + .ok() +} + +const EMAIL_LOCAL_TIME_FMT: &str = "%Y-%m-%d %I:%M %p %:z"; + +/// Format a UTC `DateTime` in the given timezone. Returns `None` when the +/// formatted result is identical to the UTC rendering (no-op for UTC hosts). +pub(crate) fn format_at_tz( + utc: chrono::DateTime, + tz: &Tz, +) -> Option +where + Tz::Offset: std::fmt::Display, +{ + let local_dt = utc.with_timezone(tz); + let formatted = local_dt.format(EMAIL_LOCAL_TIME_FMT).to_string(); + + let utc_formatted = utc.format(EMAIL_LOCAL_TIME_FMT).to_string(); + if formatted == utc_formatted { + return None; + } + Some(formatted) +} + +/// Convert a UTC email timestamp string to a human-readable local-time string. +/// +/// Accepts RFC 3339 (`"2026-05-31T10:33:00Z"`) or RFC 2822 +/// (`"Sat, 31 May 2026 10:33:00 +0000"`) input. Returns a formatted string +/// in the host's local timezone, e.g. `"2026-05-31 05:33 AM -05:00"`, +/// so the agent can present local times without UTC arithmetic. +/// +/// The raw `date` field is always preserved alongside this field so +/// internal sorting, deduplication, and debugging remain UTC-based. +/// +/// Returns `None` when the input cannot be parsed or the output format +/// would be identical to the UTC input (no-op for UTC hosts). +pub(crate) fn format_email_local_time(date_str: &str) -> Option { + let utc = parse_email_date(date_str)?; + format_at_tz(utc, &chrono::Local) +} + /// Map one raw Composio message object to its slim counterpart. /// /// Body source picked by [`extract_markdown_body`]: @@ -346,6 +394,10 @@ fn reshape_message(raw: Value) -> Value { let markdown = extract_markdown_body(&obj); let attachments = extract_attachments(&obj); + // Compute a local-time representation of the UTC `date` so the agent + // presents times in the user's timezone rather than quoting raw UTC. + let date_local = date.as_str().and_then(format_email_local_time); + let mut out = Map::new(); out.insert("id".into(), id); out.insert("threadId".into(), thread_id); @@ -353,6 +405,9 @@ fn reshape_message(raw: Value) -> Value { out.insert("from".into(), sender); out.insert("to".into(), to); out.insert("date".into(), date); + if let Some(local) = date_local { + out.insert("date_local".into(), Value::String(local)); + } out.insert("labels".into(), labels); if !list_unsubscribe.is_null() { out.insert("list_unsubscribe".into(), list_unsubscribe); diff --git a/src/openhuman/memory_sync/composio/providers/gmail/post_process_tests.rs b/src/openhuman/memory_sync/composio/providers/gmail/post_process_tests.rs index 102cbc19d..a143e95ab 100644 --- a/src/openhuman/memory_sync/composio/providers/gmail/post_process_tests.rs +++ b/src/openhuman/memory_sync/composio/providers/gmail/post_process_tests.rs @@ -275,6 +275,63 @@ fn split_with_hint_skips_messages_with_blank_subject() { assert_eq!(slices.len(), 2); } +// ── format_email_local_time ────────────────────────────────────────────────── + +#[test] +fn format_email_local_time_returns_none_for_unparseable_date() { + assert!(super::format_email_local_time("not-a-date").is_none()); + assert!(super::format_email_local_time("").is_none()); +} + +#[test] +fn format_email_local_time_preserves_utc_raw_date_in_reshape() { + let mut v = json!({ + "messages": [{ + "messageId": "m1", + "threadId": "t1", + "subject": "Test", + "sender": "a@example.com", + "to": "b@example.com", + "messageTimestamp": "2026-05-31T10:33:00Z", + "labelIds": [], + "messageText": "body", + "payload": {} + }] + }); + post_process("GMAIL_FETCH_EMAILS", None, &mut v); + let msg = &v["messages"][0]; + assert_eq!(msg["date"], "2026-05-31T10:33:00Z"); +} + +#[test] +fn parse_email_date_accepts_rfc3339_and_rfc2822() { + assert!(super::parse_email_date("2026-05-31T10:33:00Z").is_some()); + assert!(super::parse_email_date("Sun, 31 May 2026 10:33:00 +0000").is_some()); + assert!(super::parse_email_date("not-a-date").is_none()); +} + +#[test] +fn format_at_tz_deterministic_with_fixed_offset() { + use chrono::FixedOffset; + + let utc = super::parse_email_date("2026-05-31T10:33:00Z").unwrap(); + + let est = FixedOffset::west_opt(5 * 3600).unwrap(); + let result = super::format_at_tz(utc, &est).unwrap(); + assert_eq!(result, "2026-05-31 05:33 AM -05:00"); + + let ist = FixedOffset::east_opt(5 * 3600 + 1800).unwrap(); + let result = super::format_at_tz(utc, &ist).unwrap(); + assert_eq!(result, "2026-05-31 04:03 PM +05:30"); +} + +#[test] +fn format_at_tz_returns_none_for_utc() { + let utc = super::parse_email_date("2026-05-31T10:33:00Z").unwrap(); + let utc_tz = chrono::FixedOffset::east_opt(0).unwrap(); + assert!(super::format_at_tz(utc, &utc_tz).is_none()); +} + #[test] fn apply_response_level_markdown_stashes_per_message_field() { let mut data = json!({