From dc4d7ad85b575042287705db7b5fa714ee6f3d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Mon, 9 Nov 2026 20:00:33 +0100 Subject: [PATCH] fix: save a draft again when it was sent or deleted elsewhere --- crates/flectar-mail-core/src/lib.rs | 115 +++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/flectar-mail-core/src/lib.rs b/crates/flectar-mail-core/src/lib.rs index 401ac774..9abaf86d 100644 --- a/crates/flectar-mail-core/src/lib.rs +++ b/crates/flectar-mail-core/src/lib.rs @@ -4734,8 +4734,32 @@ impl Core { MAX_DRAFT_BODY_BYTES / (1024 * 1024) ))); } + // A save replaces the open draft, in place while it is still this + // account's draft. Otherwise, as in Thunderbird, it becomes a new + // draft, so one sent or deleted elsewhere is kept. Only an in-place + // save may reuse staged files, since the old copy's go with it. + let previous = match args.draft_id { + Some(id) => { + self.db + .read(move |conn| { + Ok(conn + .query_row( + "SELECT account_id, is_draft FROM messages WHERE id = ?1", + rusqlite::params![id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?)), + ) + .optional()?) + }) + .await? + } + None => None, + }; + let in_place = match (args.draft_id, previous) { + (Some(id), Some((account_id, true))) if account_id == args.account_id => Some(id), + _ => None, + }; let staging_root = self.paths.draft_attachments_dir(); - let reusable_paths = if let Some(draft_id) = args.draft_id { + let reusable_paths = if let Some(draft_id) = in_place { self.db .read(move |conn| { let mut statement = conn @@ -4844,7 +4868,7 @@ impl Core { None => None, }; - let draft_id = match args.draft_id { + let draft_id = match in_place { Some(id) => { let updated = tx.execute( "UPDATE messages SET subject = ?2, to_json = ?3, cc_json = ?4, @@ -11133,8 +11157,19 @@ mod draft_staging_tests { tokio::fs::write(&first_source, b"first").await.unwrap(); tokio::fs::write(&second_source, b"second").await.unwrap(); + // Fail the save after its attachment is staged. + core.db + .write(|conn| { + conn.execute_batch( + "CREATE TRIGGER fail_draft_attachments BEFORE INSERT ON draft_attachments + BEGIN SELECT RAISE(ABORT, 'forced failure'); END;", + )?; + Ok(()) + }) + .await + .unwrap(); assert!( - core.save_draft(draft_args(Some(999_999), &first_source, "first.txt")) + core.save_draft(draft_args(None, &first_source, "first.txt")) .await .is_err() ); @@ -11144,6 +11179,13 @@ mod draft_staging_tests { .count(), 0 ); + core.db + .write(|conn| { + conn.execute_batch("DROP TRIGGER fail_draft_attachments")?; + Ok(()) + }) + .await + .unwrap(); let draft_id = core .save_draft(draft_args(None, &first_source, "first.txt")) @@ -11180,6 +11222,73 @@ mod draft_staging_tests { assert!(!std::path::Path::new(&first_staged).exists()); assert_eq!(std::fs::read(second_staged).unwrap(), b"second"); } + + async fn subject(core: &Core, message_id: i64) -> String { + core.db + .read(move |conn| { + Ok(conn.query_row( + "SELECT subject FROM messages WHERE id = ?1", + rusqlite::params![message_id], + |row| row.get::<_, String>(0), + )?) + }) + .await + .unwrap() + } + + #[tokio::test] + async fn a_draft_sent_or_deleted_elsewhere_is_saved_again() { + let temp = tempfile::tempdir().unwrap(); + let core = Core::start_mail_ui(Paths::for_tests(temp.path())) + .await + .unwrap(); + core.db + .write(|conn| { + db::testutil::seed_account(conn); + Ok(()) + }) + .await + .unwrap(); + let source = temp.path().join("notes.txt"); + tokio::fs::write(&source, b"notes").await.unwrap(); + + // Deleted while still open, for example from the main window. + let deleted = core + .save_draft(draft_args(None, &source, "notes.txt")) + .await + .unwrap(); + core.delete_draft(deleted).await.unwrap(); + let mut reopened = draft_args(Some(deleted), &source, "notes.txt"); + reopened.subject = "still wanted".into(); + let resaved = core.save_draft(reopened).await.unwrap(); + // SQLite can hand the freed row id out again, so check the draft itself. + assert_eq!( + core.get_draft(resaved).await.unwrap().subject, + "still wanted" + ); + + // Sent from another device: the sent message is left as it was. + let sent = core + .save_draft(draft_args(None, &source, "notes.txt")) + .await + .unwrap(); + core.db + .write(move |conn| { + conn.execute( + "UPDATE messages SET is_draft = 0 WHERE id = ?1", + rusqlite::params![sent], + )?; + Ok(()) + }) + .await + .unwrap(); + let mut edited = draft_args(Some(sent), &source, "notes.txt"); + edited.subject = "edited".into(); + let kept = core.save_draft(edited).await.unwrap(); + assert_ne!(kept, sent); + assert_eq!(subject(&core, sent).await, "bounded draft"); + assert_eq!(subject(&core, kept).await, "edited"); + } } #[cfg(test)]