From 4eab79090330c5f80d8f66257d2635b1d72ebc76 Mon Sep 17 00:00:00 2001 From: Maxime Gaudin Date: Tue, 28 Jul 2026 07:50:55 +0200 Subject: [PATCH 1/2] fix(inbox): prevent incorrect Slack message auto-archive (#53) Guard bulk_archive_before with synced_at so backfilled messages are not archived before appearing in the inbox, and stop upsert from overwriting user archive decisions on re-sync. Co-authored-by: Cursor --- crates/void-core/src/db/messages/archive.rs | 4 +- crates/void-core/src/db/messages/upsert.rs | 1 - crates/void-core/src/db/tests/archive.rs | 96 +++++++++++++++++---- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/crates/void-core/src/db/messages/archive.rs b/crates/void-core/src/db/messages/archive.rs index 45c3f148..0f3a679c 100644 --- a/crates/void-core/src/db/messages/archive.rs +++ b/crates/void-core/src/db/messages/archive.rs @@ -15,7 +15,7 @@ pub fn bulk_archive_before( ) -> Result, DbError> { let mut sql = String::from( "SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved - FROM messages WHERE is_archived = 0 AND timestamp < ?1", + FROM messages WHERE is_archived = 0 AND timestamp < ?1 AND synced_at < ?1", ); let mut param_values: Vec> = vec![Box::new(before_ts)]; @@ -32,7 +32,7 @@ pub fn bulk_archive_before( .collect::>()?; let mut update_sql = String::from( - "UPDATE messages SET is_archived = 1 WHERE is_archived = 0 AND timestamp < ?1", + "UPDATE messages SET is_archived = 1 WHERE is_archived = 0 AND timestamp < ?1 AND synced_at < ?1", ); let mut update_params: Vec> = vec![Box::new(before_ts)]; diff --git a/crates/void-core/src/db/messages/upsert.rs b/crates/void-core/src/db/messages/upsert.rs index 24911975..08f9c5f3 100644 --- a/crates/void-core/src/db/messages/upsert.rs +++ b/crates/void-core/src/db/messages/upsert.rs @@ -31,7 +31,6 @@ pub fn upsert_row(conn: &Connection, msg: &Message) -> Result { connector = excluded.connector, sender_name = excluded.sender_name, sender_avatar_url = COALESCE(excluded.sender_avatar_url, sender_avatar_url), - is_archived = excluded.is_archived, media_type = excluded.media_type, metadata = excluded.metadata, context_id = COALESCE(excluded.context_id, context_id)", diff --git a/crates/void-core/src/db/tests/archive.rs b/crates/void-core/src/db/tests/archive.rs index 1a18d972..9941d730 100644 --- a/crates/void-core/src/db/tests/archive.rs +++ b/crates/void-core/src/db/tests/archive.rs @@ -1,5 +1,10 @@ use super::fixtures::*; +fn with_synced_at(mut msg: crate::models::Message, synced_at: i64) -> crate::models::Message { + msg.synced_at = Some(synced_at); + msg +} + #[test] fn mark_message_archived_updates_flag() { let db = test_db(); @@ -22,12 +27,21 @@ fn bulk_archive_before_archives_strictly_older_messages() { let conv = make_conversation("c1", "test-slack", "C123"); db.upsert_conversation(&conv).unwrap(); - db.upsert_message(&make_message("m1", "c1", "test-slack", "old", 1_000)) - .unwrap(); - db.upsert_message(&make_message("m2", "c1", "test-slack", "boundary", 2_000)) - .unwrap(); - db.upsert_message(&make_message("m3", "c1", "test-slack", "new", 3_000)) - .unwrap(); + db.upsert_message(&with_synced_at( + make_message("m1", "c1", "test-slack", "old", 1_000), + 1_000, + )) + .unwrap(); + db.upsert_message(&with_synced_at( + make_message("m2", "c1", "test-slack", "boundary", 2_000), + 2_000, + )) + .unwrap(); + db.upsert_message(&with_synced_at( + make_message("m3", "c1", "test-slack", "new", 3_000), + 3_000, + )) + .unwrap(); // cutoff is exclusive: timestamp < 2000 → only m1. let archived = db.bulk_archive_before(2_000, None).unwrap(); @@ -51,15 +65,14 @@ fn bulk_archive_before_respects_connector_filter() { gmail_conv.connector = "gmail".into(); db.upsert_conversation(&gmail_conv).unwrap(); - db.upsert_message(&make_message("s1", "c1", "test-slack", "slack old", 1_000)) - .unwrap(); - db.upsert_message(&make_message_with_connector( - "g1", - "c2", - "test-gmail", - "gmail old", + db.upsert_message(&with_synced_at( + make_message("s1", "c1", "test-slack", "slack old", 1_000), + 1_000, + )) + .unwrap(); + db.upsert_message(&with_synced_at( + make_message_with_connector("g1", "c2", "test-gmail", "gmail old", 1_000, "gmail"), 1_000, - "gmail", )) .unwrap(); @@ -80,11 +93,14 @@ fn bulk_archive_before_skips_already_archived() { let conv = make_conversation("c1", "test-slack", "C123"); db.upsert_conversation(&conv).unwrap(); - let mut m1 = make_message("m1", "c1", "test-slack", "already", 1_000); + let mut m1 = with_synced_at(make_message("m1", "c1", "test-slack", "already", 1_000), 1_000); m1.is_archived = true; db.upsert_message(&m1).unwrap(); - db.upsert_message(&make_message("m2", "c1", "test-slack", "fresh", 1_500)) - .unwrap(); + db.upsert_message(&with_synced_at( + make_message("m2", "c1", "test-slack", "fresh", 1_500), + 1_500, + )) + .unwrap(); let archived = db.bulk_archive_before(2_000, None).unwrap(); let ids: Vec<&str> = archived.iter().map(|m| m.id.as_str()).collect(); @@ -107,3 +123,49 @@ fn bulk_archive_before_empty_result_when_nothing_matches() { assert!(archived.is_empty(), "no message older than cutoff"); assert!(!db.get_message("m1").unwrap().unwrap().is_archived); } + +#[test] +fn bulk_archive_before_skips_recently_synced_messages() { + let db = test_db(); + let conv = make_conversation("c1", "test-slack", "C123"); + db.upsert_conversation(&conv).unwrap(); + + db.upsert_message(&with_synced_at( + make_message("m1", "c1", "test-slack", "old send, fresh sync", 1_000), + 5_000, + )) + .unwrap(); + + let archived = db.bulk_archive_before(3_000, None).unwrap(); + assert!( + archived.is_empty(), + "recently synced message must not be bulk-archived" + ); + assert!( + !db.get_message("m1").unwrap().unwrap().is_archived, + "message with old timestamp but recent synced_at stays unarchived" + ); +} + +#[test] +fn upsert_preserves_user_archived_flag() { + let db = test_db(); + let conv = make_conversation("c1", "test-slack", "C123"); + db.upsert_conversation(&conv).unwrap(); + + let msg = make_message("m1", "c1", "test-slack", "hello", 1_000); + db.upsert_message(&msg).unwrap(); + assert!(db.mark_message_archived("m1").unwrap()); + + let mut resync = make_message("m1", "c1", "test-slack", "hello edited", 1_000); + resync.is_archived = false; + resync.body = Some("hello edited".into()); + db.upsert_message(&resync).unwrap(); + + let loaded = db.get_message("m1").unwrap().unwrap(); + assert!( + loaded.is_archived, + "re-sync with is_archived=false must not un-archive user decision" + ); + assert_eq!(loaded.body.as_deref(), Some("hello edited")); +} From 87ce94b7da9dd20b5434bcf33a5ff1948a4178bf Mon Sep 17 00:00:00 2001 From: Maxime Gaudin Date: Tue, 28 Jul 2026 08:02:37 +0200 Subject: [PATCH 2/2] style: fix formatting Co-authored-by: Cursor --- crates/void-core/src/db/tests/archive.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/void-core/src/db/tests/archive.rs b/crates/void-core/src/db/tests/archive.rs index 9941d730..64f9d14d 100644 --- a/crates/void-core/src/db/tests/archive.rs +++ b/crates/void-core/src/db/tests/archive.rs @@ -93,7 +93,10 @@ fn bulk_archive_before_skips_already_archived() { let conv = make_conversation("c1", "test-slack", "C123"); db.upsert_conversation(&conv).unwrap(); - let mut m1 = with_synced_at(make_message("m1", "c1", "test-slack", "already", 1_000), 1_000); + let mut m1 = with_synced_at( + make_message("m1", "c1", "test-slack", "already", 1_000), + 1_000, + ); m1.is_archived = true; db.upsert_message(&m1).unwrap(); db.upsert_message(&with_synced_at(