fix(email): add date_local field with host timezone to email reshaper (#3128) (#3143)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-06-01 22:03:34 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Steven Enamakel
parent e1a8f16778
commit d666ce61d7
3 changed files with 123 additions and 14 deletions
@@ -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' }));
@@ -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<chrono::DateTime<chrono::Utc>> {
date_str
.parse::<chrono::DateTime<chrono::Utc>>()
.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<Tz: chrono::TimeZone>(
utc: chrono::DateTime<chrono::Utc>,
tz: &Tz,
) -> Option<String>
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<String> {
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);
@@ -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!({