diff --git a/rust/cloud-storage/email_db_client/.sqlx/query-5c235a72a986b83ee63c6fb7eea31d43d23d94a8a007c9e298aa4e3651e5589e.json b/rust/cloud-storage/email_db_client/.sqlx/query-5c235a72a986b83ee63c6fb7eea31d43d23d94a8a007c9e298aa4e3651e5589e.json deleted file mode 100644 index a0292ade5a8..00000000000 --- a/rust/cloud-storage/email_db_client/.sqlx/query-5c235a72a986b83ee63c6fb7eea31d43d23d94a8a007c9e298aa4e3651e5589e.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n t.id as thread_id,\n COALESCE(\n MIN(m.sent_at) FILTER (WHERE m.is_draft = false),\n MIN(m.updated_at)\n ) as \"first_message_ts!\",\n COALESCE(\n MAX(m.sent_at) FILTER (WHERE m.is_draft = false),\n MAX(m.updated_at)\n ) as \"last_message_ts!\",\n -- Last time user viewed this thread\n uh.updated_at as \"viewed_at?\",\n latest_msg.snippet,\n latest_msg.subject as \"subject?\",\n l.macro_id,\n latest_msg.sender as sender,\n latest_msg.pretty_sender as \"pretty_sender!\"\n FROM email_threads t\n INNER JOIN email_messages m ON m.thread_id = t.id AND m.link_id = $1\n LEFT JOIN email_user_history uh ON uh.thread_id = t.id AND uh.link_id = $1\n LEFT JOIN email_links l ON l.id = t.link_id\n -- LATERAL join to get snippet/subject/sender from the single latest message.\n -- This subquery runs once per thread and returns exactly one message's data.\n LEFT JOIN LATERAL (\n SELECT\n m2.snippet,\n m2.subject,\n c.email_address as sender,\n COALESCE(c.name, c.email_address) as pretty_sender\n FROM email_messages m2\n LEFT JOIN email_contacts c ON c.id = m2.from_contact_id\n WHERE m2.thread_id = t.id\n AND m2.link_id = $1\n ORDER BY\n -- Primary sort: most recent timestamp (use sent_at if available, otherwise updated_at)\n COALESCE(m2.sent_at, m2.updated_at) DESC NULLS LAST,\n -- Secondary sort: prefer non-drafts over drafts when timestamps are equal\n -- (0 for non-drafts comes before 1 for drafts in ASC order)\n (CASE WHEN m2.is_draft THEN 1 ELSE 0 END) ASC\n LIMIT 1\n ) latest_msg ON true\n WHERE t.id = ANY($2)\n AND t.link_id = $1\n GROUP BY\n t.id,\n uh.updated_at,\n latest_msg.snippet,\n latest_msg.subject,\n l.macro_id,\n latest_msg.sender,\n latest_msg.pretty_sender\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "thread_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "first_message_ts!", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "last_message_ts!", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "viewed_at?", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "snippet", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "subject?", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "macro_id", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "sender", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "pretty_sender!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "UuidArray" - ] - }, - "nullable": [ - false, - null, - null, - false, - true, - true, - false, - false, - null - ] - }, - "hash": "5c235a72a986b83ee63c6fb7eea31d43d23d94a8a007c9e298aa4e3651e5589e" -} diff --git a/rust/cloud-storage/email_db_client/.sqlx/query-b14a0b4df422aeacd17d6d4652f01696e684b2c10fbc4cfe92e07db8fb8ef1c4.json b/rust/cloud-storage/email_db_client/.sqlx/query-b14a0b4df422aeacd17d6d4652f01696e684b2c10fbc4cfe92e07db8fb8ef1c4.json new file mode 100644 index 00000000000..3d0f4988f94 --- /dev/null +++ b/rust/cloud-storage/email_db_client/.sqlx/query-b14a0b4df422aeacd17d6d4652f01696e684b2c10fbc4cfe92e07db8fb8ef1c4.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n t.id as thread_id,\n -- Return timestamps from the chosen messages directly\n COALESCE(earliest_msg.sent_at, earliest_msg.updated_at) as \"first_message_ts!\",\n COALESCE(latest_msg.sent_at, latest_msg.updated_at) as \"last_message_ts!\",\n -- Last time user viewed this thread\n uh.updated_at as \"viewed_at?\",\n latest_msg.snippet,\n earliest_msg.subject as \"subject?\",\n l.macro_id,\n latest_msg.sender as sender,\n latest_msg.pretty_sender as \"pretty_sender!\"\n FROM email_threads t\n LEFT JOIN email_user_history uh ON uh.thread_id = t.id AND uh.link_id = $1\n LEFT JOIN email_links l ON l.id = t.link_id\n -- LATERAL join for LATEST message\n -- JOIN (Inner) acts as a filter to ensure we only return threads that actually have messages\n JOIN LATERAL (\n SELECT\n m2.snippet,\n m2.sent_at,\n m2.updated_at,\n c.email_address as sender,\n COALESCE(c.name, c.email_address) as pretty_sender\n FROM email_messages m2\n LEFT JOIN email_contacts c ON c.id = m2.from_contact_id\n WHERE m2.thread_id = t.id\n AND m2.link_id = $1\n ORDER BY\n -- 1. Priority: Non-drafts with valid sent_at come first (0), everything else is fallback (1)\n (CASE WHEN m2.is_draft = false AND m2.sent_at IS NOT NULL THEN 0 ELSE 1 END) ASC,\n -- 2. Sort by time (Newest first)\n COALESCE(m2.sent_at, m2.updated_at) DESC NULLS LAST\n LIMIT 1\n ) latest_msg ON true\n -- LATERAL join for EARLIEST message\n LEFT JOIN LATERAL (\n SELECT\n m3.subject,\n m3.sent_at,\n m3.updated_at\n FROM email_messages m3\n WHERE m3.thread_id = t.id\n AND m3.link_id = $1\n ORDER BY\n -- 1. Priority: Non-drafts with valid sent_at come first (0), everything else is fallback (1)\n (CASE WHEN m3.is_draft = false AND m3.sent_at IS NOT NULL THEN 0 ELSE 1 END) ASC,\n -- 2. Sort by time (Oldest first)\n COALESCE(m3.sent_at, m3.updated_at) ASC NULLS LAST\n LIMIT 1\n ) earliest_msg ON true\n WHERE t.id = ANY($2)\n AND t.link_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "thread_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "first_message_ts!", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "last_message_ts!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "viewed_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "snippet", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "subject?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "macro_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "sender", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "pretty_sender!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + false, + null, + null, + false, + true, + true, + false, + false, + null + ] + }, + "hash": "b14a0b4df422aeacd17d6d4652f01696e684b2c10fbc4cfe92e07db8fb8ef1c4" +} diff --git a/rust/cloud-storage/email_db_client/fixtures/get_thread_summary_info.sql b/rust/cloud-storage/email_db_client/fixtures/get_thread_summary_info.sql index a04058f0d55..99a2918b111 100644 --- a/rust/cloud-storage/email_db_client/fixtures/get_thread_summary_info.sql +++ b/rust/cloud-storage/email_db_client/fixtures/get_thread_summary_info.sql @@ -348,4 +348,48 @@ INSERT INTO email_user_history (link_id, thread_id, created_at, updated_at) VALUES ('00000000-0000-0000-0000-00000000001c', '00000000-0000-0000-0000-000000000301', NOW(), - '2025-01-10 13:00:00 +00:00'); \ No newline at end of file + '2025-01-10 13:00:00 +00:00'); + +------------------------------------------------------------ +-- Thread 7: Subject changes between messages +-- Validate that we get the subject from the EARLIEST message +------------------------------------------------------------ + +INSERT INTO email_threads (id, link_id, inbox_visible, is_read, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-000000000307', + '00000000-0000-0000-0000-00000000001c', + true, false, NOW(), NOW()); + +-- Earliest message: "Original Subject" +INSERT INTO email_messages (id, thread_id, link_id, provider_id, is_sent, from_contact_id, internal_date_ts, + sent_at, is_draft, snippet, subject, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-0000000e0617', + '00000000-0000-0000-0000-000000000307', + '00000000-0000-0000-0000-00000000001c', + 'provider-msg-617', + FALSE, + '00000000-0000-0000-0000-0000000c0020', + '2025-01-16 10:00:00 +00:00', + '2025-01-16 10:00:00 +00:00', + false, + 'First message', + 'Original Subject', + '2025-01-16 09:00:00 +00:00', + '2025-01-16 09:30:00 +00:00'); + +-- Latest message: "Reply Subject" +INSERT INTO email_messages (id, thread_id, link_id, provider_id, is_sent, from_contact_id, internal_date_ts, + sent_at, is_draft, snippet, subject, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-0000000e0618', + '00000000-0000-0000-0000-000000000307', + '00000000-0000-0000-0000-00000000001c', + 'provider-msg-618', + FALSE, + '00000000-0000-0000-0000-0000000c0021', + '2025-01-16 12:00:00 +00:00', + '2025-01-16 12:00:00 +00:00', + false, + 'Reply message', + 'Reply Subject', + '2025-01-16 11:00:00 +00:00', + '2025-01-16 11:30:00 +00:00'); \ No newline at end of file diff --git a/rust/cloud-storage/email_db_client/src/user_history/mod.rs b/rust/cloud-storage/email_db_client/src/user_history/mod.rs index b2a25b1dd80..d9e8c124836 100644 --- a/rust/cloud-storage/email_db_client/src/user_history/mod.rs +++ b/rust/cloud-storage/email_db_client/src/user_history/mod.rs @@ -35,13 +35,15 @@ pub async fn upsert_user_history( /// Get summary information for multiple email threads. /// /// Returns a HashMap of thread history info including: -/// - `created_at`: timestamp of the first message in the thread (prefers sent_at from non-drafts, falls back to updated_at) -/// - `updated_at`: timestamp of the last message in the thread (prefers sent_at from non-drafts, falls back to updated_at) +/// - `created_at`: timestamp of the first message in the thread +/// - `updated_at`: timestamp of the last message in the thread /// - `viewed_at`: last time the user opened/viewed the thread (only included if >= updated_at) -/// - `snippet`, `subject`, `sender`, `pretty_sender`: from the latest message in the thread +/// - `snippet`, `sender`, `pretty_sender`: from the *latest* message in the thread +/// - `subject`: from the *earliest* message in the thread (so it doesn't include "Re:"s /// -/// The "latest message" is determined by: -/// 1. Message with the most recent sent_at (if available) or updated_at +/// The "earliest" and "latest" messages are determined by: +/// 1. Priority: Non-drafts with a valid `sent_at` take precedence over drafts (or messages without `sent_at`). +/// 2. Sort: Chronological order of `sent_at` (or `updated_at` fallback). #[tracing::instrument(skip(pool), err)] pub async fn get_thread_summary_info( pool: &PgPool, @@ -53,65 +55,67 @@ pub async fn get_thread_summary_info( } let rows = sqlx::query!( - r#" - SELECT - t.id as thread_id, - COALESCE( - MIN(m.sent_at) FILTER (WHERE m.is_draft = false), - MIN(m.updated_at) - ) as "first_message_ts!", - COALESCE( - MAX(m.sent_at) FILTER (WHERE m.is_draft = false), - MAX(m.updated_at) - ) as "last_message_ts!", - -- Last time user viewed this thread - uh.updated_at as "viewed_at?", - latest_msg.snippet, - latest_msg.subject as "subject?", - l.macro_id, - latest_msg.sender as sender, - latest_msg.pretty_sender as "pretty_sender!" - FROM email_threads t - INNER JOIN email_messages m ON m.thread_id = t.id AND m.link_id = $1 - LEFT JOIN email_user_history uh ON uh.thread_id = t.id AND uh.link_id = $1 - LEFT JOIN email_links l ON l.id = t.link_id - -- LATERAL join to get snippet/subject/sender from the single latest message. - -- This subquery runs once per thread and returns exactly one message's data. - LEFT JOIN LATERAL ( + r#" SELECT - m2.snippet, - m2.subject, - c.email_address as sender, - COALESCE(c.name, c.email_address) as pretty_sender - FROM email_messages m2 - LEFT JOIN email_contacts c ON c.id = m2.from_contact_id - WHERE m2.thread_id = t.id - AND m2.link_id = $1 - ORDER BY - -- Primary sort: most recent timestamp (use sent_at if available, otherwise updated_at) - COALESCE(m2.sent_at, m2.updated_at) DESC NULLS LAST, - -- Secondary sort: prefer non-drafts over drafts when timestamps are equal - -- (0 for non-drafts comes before 1 for drafts in ASC order) - (CASE WHEN m2.is_draft THEN 1 ELSE 0 END) ASC - LIMIT 1 - ) latest_msg ON true - WHERE t.id = ANY($2) - AND t.link_id = $1 - GROUP BY - t.id, - uh.updated_at, - latest_msg.snippet, - latest_msg.subject, - l.macro_id, - latest_msg.sender, - latest_msg.pretty_sender - "#, - link_id, - thread_ids - ) - .fetch_all(pool) - .await - .map_err(|e| anyhow!("Failed to fetch thread summary info: {}", e))?; + t.id as thread_id, + -- Return timestamps from the chosen messages directly + COALESCE(earliest_msg.sent_at, earliest_msg.updated_at) as "first_message_ts!", + COALESCE(latest_msg.sent_at, latest_msg.updated_at) as "last_message_ts!", + -- Last time user viewed this thread + uh.updated_at as "viewed_at?", + latest_msg.snippet, + earliest_msg.subject as "subject?", + l.macro_id, + latest_msg.sender as sender, + latest_msg.pretty_sender as "pretty_sender!" + FROM email_threads t + LEFT JOIN email_user_history uh ON uh.thread_id = t.id AND uh.link_id = $1 + LEFT JOIN email_links l ON l.id = t.link_id + -- LATERAL join for LATEST message + -- JOIN (Inner) acts as a filter to ensure we only return threads that actually have messages + JOIN LATERAL ( + SELECT + m2.snippet, + m2.sent_at, + m2.updated_at, + c.email_address as sender, + COALESCE(c.name, c.email_address) as pretty_sender + FROM email_messages m2 + LEFT JOIN email_contacts c ON c.id = m2.from_contact_id + WHERE m2.thread_id = t.id + AND m2.link_id = $1 + ORDER BY + -- 1. Priority: Non-drafts with valid sent_at come first (0), everything else is fallback (1) + (CASE WHEN m2.is_draft = false AND m2.sent_at IS NOT NULL THEN 0 ELSE 1 END) ASC, + -- 2. Sort by time (Newest first) + COALESCE(m2.sent_at, m2.updated_at) DESC NULLS LAST + LIMIT 1 + ) latest_msg ON true + -- LATERAL join for EARLIEST message + LEFT JOIN LATERAL ( + SELECT + m3.subject, + m3.sent_at, + m3.updated_at + FROM email_messages m3 + WHERE m3.thread_id = t.id + AND m3.link_id = $1 + ORDER BY + -- 1. Priority: Non-drafts with valid sent_at come first (0), everything else is fallback (1) + (CASE WHEN m3.is_draft = false AND m3.sent_at IS NOT NULL THEN 0 ELSE 1 END) ASC, + -- 2. Sort by time (Oldest first) + COALESCE(m3.sent_at, m3.updated_at) ASC NULLS LAST + LIMIT 1 + ) earliest_msg ON true + WHERE t.id = ANY($2) + AND t.link_id = $1 + "#, + link_id, + thread_ids + ) + .fetch_all(pool) + .await + .map_err(|e| anyhow!("Failed to fetch thread summary info: {}", e))?; let mut result = HashMap::new(); diff --git a/rust/cloud-storage/email_db_client/src/user_history/test.rs b/rust/cloud-storage/email_db_client/src/user_history/test.rs index ec9d2be456b..f0d1ad85ef4 100644 --- a/rust/cloud-storage/email_db_client/src/user_history/test.rs +++ b/rust/cloud-storage/email_db_client/src/user_history/test.rs @@ -225,3 +225,32 @@ async fn get_thread_summary_info_viewed_at_logic(pool: Pool) -> Result Ok(()) } + +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../fixtures", scripts("get_thread_summary_info")) +)] +async fn get_thread_summary_info_subject_is_from_earliest_message( + pool: Pool, +) -> Result<()> { + const _: &sqlx::migrate::Migrator = &MACRO_DB_MIGRATIONS; + + let link_id = Uuid::parse_str("00000000-0000-0000-0000-00000000001c")?; + let thread_id = Uuid::parse_str("00000000-0000-0000-0000-000000000307")?; + + let result = get_thread_summary_info(&pool, link_id, &[thread_id]).await?; + + assert_eq!(result.len(), 1); + let info = result.get(&thread_id).unwrap(); + + // The thread has two messages: + // 1. Earliest: Subject "Original Subject" + // 2. Latest: Subject "Reply Subject" + // We want the subject of the earliest message. + assert_eq!(info.subject, Some("Original Subject".to_string())); + + // Verify we are still getting snippet/sender from the LATEST message + assert_eq!(info.snippet, Some("Reply message".to_string())); + + Ok(()) +}