From 5bef9e3afa10b1c30966c6c0ef939e539b7dd29a Mon Sep 17 00:00:00 2001 From: bigfish Date: Sat, 12 Sep 2026 08:13:48 +0800 Subject: [PATCH 1/6] fix IMAP history sync and add account controls --- crates/flectar-mail-core/src/imap/mod.rs | 7 ++- src/mail.rs | 7 +++ src/main.rs | 80 ++++++++++++++++++++++++ ui/app.slint | 4 ++ ui/dialogs/settings-dialog.slint | 42 +++++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) diff --git a/crates/flectar-mail-core/src/imap/mod.rs b/crates/flectar-mail-core/src/imap/mod.rs index 8cfbaa4..9844c9f 100644 --- a/crates/flectar-mail-core/src/imap/mod.rs +++ b/crates/flectar-mail-core/src/imap/mod.rs @@ -531,7 +531,12 @@ pub async fn fetch_headers(session: &mut Session, uid_set: &str) -> Result Result> { - let query = format!("(UID FLAGS INTERNALDATE RFC822.SIZE BODYSTRUCTURE {HEADER_FIELDS})"); + // Keep header sync independent from BODYSTRUCTURE. Some IMAP servers + // (notably QQ Mail) emit malformed BODYSTRUCTURE responses for individual + // messages; including it here makes async-imap abort the entire stream and + // prevents every historical header from being stored. MIME plans are + // fetched lazily by the body backfill path when needed. + let query = format!("(UID FLAGS INTERNALDATE RFC822.SIZE {HEADER_FIELDS})"); let mut out = Vec::new(); { let mut stream = session diff --git a/src/mail.rs b/src/mail.rs index cafc505..b35b5b4 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -744,6 +744,13 @@ impl CoreMailSource { .map_err(|error| error.to_string()) } + pub async fn remove_account(&self, account_id: i64) -> Result<(), String> { + self.core + .remove_account(account_id) + .await + .map_err(|error| error.to_string()) + } + pub async fn list_contacts( &self, prefix: String, diff --git a/src/main.rs b/src/main.rs index bd3c576..a2ddf28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3458,6 +3458,48 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> }); }); + let app_weak = app.as_weak(); + let state_for_account_delete = Rc::clone(&state); + let runtime_for_account_delete = Rc::clone(&runtime); + let ui_task_tx_for_account_delete = ui_task_tx.clone(); + app.on_delete_account(move |account_id| { + let Some(app) = app_weak.upgrade() else { + return; + }; + let Some(core) = state_for_account_delete.borrow().core.clone() else { + app.set_sync_status(UiMessage::plain("Account management requires local mail data.")); + return; + }; + let account_id = i64::from(account_id); + app.set_sync_status(UiMessage::plain("Removing account…")); + let updates = ui_task_tx_for_account_delete.clone(); + runtime_for_account_delete.spawn(async move { + let result = core.remove_account(account_id).await; + let accounts = match ( + core.load_accounts().await, + core.load_account_configs().await, + ) { + (Ok(accounts), Ok(configs)) => Some((accounts, configs)), + _ => None, + }; + let message = match result { + Ok(()) => UiMessage::plain("Account removed from this device."), + Err(error) => UiMessage::detail("Could not remove account: {}", error), + }; + let _ = updates + .send(UiTaskUpdate { + message, + accounts, + calendar_connections: None, + calendar_error: None, + clear_account_form: false, + finishes_oauth: false, + close_to_tray: None, + }) + .await; + }); + }); + let app_weak = app.as_weak(); let state_for_mail_history = Rc::clone(&state); let runtime_for_mail_history = Rc::clone(&runtime); @@ -3649,6 +3691,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> let sync_runtime = Rc::clone(&runtime); let sync_progress = Rc::clone(&sync_in_progress); let sync_app = app.as_weak(); + let sync_tx_for_all = sync_tx.clone(); app.on_drain_sync_updates(move || { while let Ok(update) = sync_rx.borrow_mut().try_recv() { sync_progress.set(false); @@ -3722,6 +3765,43 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> }); }); + let app_weak = app.as_weak(); + let state_for_account_sync = Rc::clone(&state); + let runtime_for_account_sync = Rc::clone(&runtime); + let sync_progress_for_account_click = Rc::clone(&sync_in_progress); + app.on_sync_account(move |account_id| { + let Some(app) = app_weak.upgrade() else { + return; + }; + if sync_progress_for_account_click.replace(true) { + return; + } + let (core, scope) = { + let state = state_for_account_sync.borrow(); + (state.core.clone(), state.scope.clone()) + }; + let Some(core) = core else { + sync_progress_for_account_click.set(false); + app.set_sync_status(UiMessage::plain( + "Local mail data is unavailable; preview data is read-only.", + )); + return; + }; + app.set_sync_in_progress(true); + app.set_sync_status(UiMessage::plain("Synchronizing account…")); + let sync_tx = sync_tx_for_all.clone(); + let account_id = i64::from(account_id); + runtime_for_account_sync.spawn(async move { + let result = core.sync_now(Some(account_id)).await; + let metadata = if result.is_ok() { + core.load_mail_metadata(&scope).await.ok() + } else { + None + }; + let _ = sync_tx.send(SyncUpdate { result, metadata }).await; + }); + }); + let app_weak = app.as_weak(); let state_for_check = Rc::clone(&state); let runtime_for_check = Rc::clone(&runtime); diff --git a/ui/app.slint b/ui/app.slint index e8c8a77..69b2409 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -429,6 +429,8 @@ export component AppWindow inherits Window { callback load_more(); callback toggle_settings(); callback sync_mail(); + callback sync_account(int); + callback delete_account(int); callback message_action(string); callback message_action_for_email(int, string); callback set_email_checked(int, bool); @@ -1372,6 +1374,8 @@ export component AppWindow inherits Window { save_mark_read_on_open(enabled) => { root.save_mark_read_on_open(enabled); } set_close_to_tray(enabled) => { root.set_close_to_tray(enabled); } sync_mail => { root.sync_mail(); } + sync_account(account-id) => { root.sync_account(account-id); } + delete_account(account-id) => { root.delete_account(account-id); } add_password_account(protocol, email, username, password, jmap-url, imap-host, imap-port, smtp-host, smtp-port) => { root.add_password_account(protocol, email, username, password, jmap-url, imap-host, imap-port, smtp-host, smtp-port); } diff --git a/ui/dialogs/settings-dialog.slint b/ui/dialogs/settings-dialog.slint index f903c5c..ab40819 100644 --- a/ui/dialogs/settings-dialog.slint +++ b/ui/dialogs/settings-dialog.slint @@ -86,6 +86,9 @@ export component SettingsDialog inherits Rectangle { in-out property imap_form_open: false; private property imap_editing: false; in-out property delete_data_confirmation: false; + in-out property delete_account_confirmation: false; + in-out property delete_account_id: -1; + in-out property delete_account_name: ""; private property compact-nav-open: false; callback save_theme(string); callback save_theme_preset(string); @@ -99,6 +102,8 @@ export component SettingsDialog inherits Rectangle { callback save_mark_read_on_open(bool); callback set_close_to_tray(bool); callback sync_mail(); + callback sync_account(int); + callback delete_account(int); callback add_password_account(string, string, string, string, string, string, string, string, string); callback start_oauth(string); callback reauth_account(int); @@ -727,6 +732,25 @@ export component SettingsDialog inherits Rectangle { } } } + Button { + width: 76px; + height: 30px; + text: @tr("Remove"); + destructive: true; + enabled: !root.oauth_in_progress && !root.oauth_settings_saving; + clicked => { + root.delete_account_id = account.id; + root.delete_account_name = account.email; + root.delete_account_confirmation = true; + } + } + Button { + width: 68px; + height: 30px; + text: @tr("Sync"); + enabled: !root.sync_in_progress && !root.oauth_settings_saving; + clicked => { root.sync_account(account.id); } + } if !root.compact && account.calendar_connected : HorizontalBox { width: 122px; spacing: 8px; @@ -1292,4 +1316,22 @@ export component SettingsDialog inherits Rectangle { muted-color: root.secondary-text; confirmed => { root.delete_all_data(); } } + + ConfirmationDialog { + width: parent.width; + height: parent.height; + open <=> root.delete_account_confirmation; + title: @tr("Remove account?"); + description: @tr("Remove this account and its downloaded mail from this device."); + detail: @tr("Messages on the mail server will not be deleted."); + confirm-label: @tr("Remove account"); + cancel-label: @tr("Keep account"); + icon: @image-url("../icons/phosphor/trash-fill.svg"); + surface: root.surface; + dialog-border-color: root.border-subtle; + title-color: root.title-color; + body-color: root.body-text; + muted-color: root.secondary-text; + confirmed => { root.delete_account(root.delete_account_id); } + } } From 3ed439c8428b1de43cc4dd25b6fe17bd4fe3c5f1 Mon Sep 17 00:00:00 2001 From: bigfish Date: Sat, 12 Sep 2026 08:57:33 +0800 Subject: [PATCH 2/6] fix account update task initialization --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index a2ddf28..62ccb22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3493,6 +3493,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box> calendar_connections: None, calendar_error: None, clear_account_form: false, + finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) From d8d55de5085262c54d3acff6aec2d94a3508d163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Sat, 12 Sep 2026 21:10:19 +0000 Subject: [PATCH 3/6] fix: mime fallback --- crates/flectar-mail-core/src/error.rs | 6 +- crates/flectar-mail-core/src/imap/mod.rs | 143 ++++-- crates/flectar-mail-core/src/sync/engine.rs | 145 ++++-- .../src/sync/mime_recovery_tests.rs | 474 ++++++++++++++++++ 4 files changed, 705 insertions(+), 63 deletions(-) create mode 100644 crates/flectar-mail-core/src/sync/mime_recovery_tests.rs diff --git a/crates/flectar-mail-core/src/error.rs b/crates/flectar-mail-core/src/error.rs index b863e1d..84ce2ee 100644 --- a/crates/flectar-mail-core/src/error.rs +++ b/crates/flectar-mail-core/src/error.rs @@ -10,6 +10,10 @@ pub enum CoreError { Network(String), #[error("imap error: {0}")] Imap(String), + /// A selective FETCH response could not be parsed. Its session must be + /// discarded; explicit opens may retry using the complete message. + #[error("imap response parse error: {0}")] + ImapParse(String), #[error("jmap error: {0}")] Jmap(String), #[error("send status uncertain: {0}")] @@ -49,7 +53,7 @@ impl CoreError { CoreError::Db(_) => "db", CoreError::Io(_) => "io", CoreError::Network(_) => "network", - CoreError::Imap(_) => "imap", + CoreError::Imap(_) | CoreError::ImapParse(_) => "imap", CoreError::Jmap(_) => "jmap", CoreError::SendUncertain(_) => "send_uncertain", CoreError::Smtp(_) => "smtp", diff --git a/crates/flectar-mail-core/src/imap/mod.rs b/crates/flectar-mail-core/src/imap/mod.rs index 9844c9f..37e2b19 100644 --- a/crates/flectar-mail-core/src/imap/mod.rs +++ b/crates/flectar-mail-core/src/imap/mod.rs @@ -531,11 +531,9 @@ pub async fn fetch_headers(session: &mut Session, uid_set: &str) -> Result Result> { - // Keep header sync independent from BODYSTRUCTURE. Some IMAP servers - // (notably QQ Mail) emit malformed BODYSTRUCTURE responses for individual - // messages; including it here makes async-imap abort the entire stream and - // prevents every historical header from being stored. MIME plans are - // fetched lazily by the body backfill path when needed. + // Keep header sync independent from BODYSTRUCTURE: an incompatible MIME + // response must not prevent historical headers from being stored. MIME + // plans are fetched separately when preparing or opening message bodies. let query = format!("(UID FLAGS INTERNALDATE RFC822.SIZE {HEADER_FIELDS})"); let mut out = Vec::new(); { @@ -572,6 +570,83 @@ pub struct FetchedMimePlan { const MIME_PLAN_QUERY: &str = "(UID BODYSTRUCTURE)"; +fn selective_fetch_error(error: async_imap::error::Error) -> CoreError { + match error { + // Do not include response bytes: they can contain private headers. + async_imap::error::Error::Parse(_) => { + CoreError::ImapParse("could not parse selective FETCH response".into()) + } + // async-imap 0.11.3 wraps its wire decoder's nom errors in Io(Other) + // rather than Parse. Match that decoder format only, never NO/BAD or + // an arbitrary transport error. Keep the embedded response private. + async_imap::error::Error::Io(ref detail) + if detail.kind() == std::io::ErrorKind::Other + && (detail.to_string().starts_with("Error(") + || detail.to_string().starts_with("Failure(")) + && detail.to_string().contains(" during parsing of ") => + { + CoreError::ImapParse("could not parse selective FETCH response".into()) + } + error => CoreError::Imap(error.to_string()), + } +} + +/// async-imap's FETCH stream discards the tagged completion status. Read +/// selective responses directly so NO/BAD cannot masquerade as missing MIME. +/// The main actor independently reconciles unsolicited flags and expunges. +async fn selective_fetch( + session: &mut Session, + uid_set: &str, + query: &str, + mut consume: impl FnMut(u32, &[async_imap::imap_proto::AttributeValue<'_>]), +) -> Result<()> { + use async_imap::imap_proto::{AttributeValue, Response, Status}; + let tag = session + .run_command(format!("UID FETCH {uid_set} {query}")) + .await + .map_err(selective_fetch_error)?; + loop { + let response = session + .read_response() + .await + .map_err(|error| selective_fetch_error(error.into()))? + .ok_or_else(|| CoreError::Imap("connection closed before FETCH completed".into()))?; + match response.parsed() { + Response::Done { + tag: received, + status, + code, + information, + } if *received == tag => { + return match status { + Status::Ok => Ok(()), + _ => Err(CoreError::Imap(format!( + "FETCH rejected: {status:?}, {code:?}, {information:?}" + ))), + }; + } + Response::Done { .. } => { + return Err(CoreError::ImapParse( + "unexpected FETCH completion tag".into(), + )); + } + Response::Data { + status: Status::Bye, + .. + } => return Err(CoreError::Imap("server closed FETCH session".into())), + Response::Fetch(_, attributes) => { + if let Some(uid) = attributes.iter().find_map(|attr| match attr { + AttributeValue::Uid(uid) => Some(*uid), + _ => None, + }) { + consume(uid, attributes); + } + } + _ => {} + } + } +} + async fn fetch_mime_plans_batch_inner( session: &mut Session, uids: &[u32], @@ -582,19 +657,18 @@ async fn fetch_mime_plans_batch_inner( } let requested: std::collections::HashSet = uids.iter().copied().collect(); let mut result = std::collections::BTreeMap::::new(); - let mut stream = session - .uid_fetch(&uid_set, MIME_PLAN_QUERY) - .await - .map_err(|error| CoreError::Imap(error.to_string()))?; - while let Some(item) = stream.next().await { - let fetch = item.map_err(|error| CoreError::Imap(error.to_string()))?; - let Some(uid) = fetch.uid.filter(|uid| requested.contains(uid)) else { - continue; - }; - if let Some(bodystructure) = fetch.bodystructure() { - result.insert(uid, mime::plan_bodystructure(bodystructure)); + selective_fetch(session, &uid_set, MIME_PLAN_QUERY, |uid, attributes| { + if requested.contains(&uid) { + for attribute in attributes { + if let async_imap::imap_proto::AttributeValue::BodyStructure(bodystructure) = + attribute + { + result.insert(uid, mime::plan_bodystructure(bodystructure)); + } + } } - } + }) + .await?; Ok(result .into_iter() .map(|(uid, plan)| FetchedMimePlan { uid, plan }) @@ -699,36 +773,37 @@ async fn fetch_sections_batch_inner( let requested: std::collections::HashSet = uids.iter().copied().collect(); let mut result = std::collections::BTreeMap::>::new(); - let mut stream = session - .uid_fetch(&uid_set, &query) - .await - .map_err(|error| CoreError::Imap(error.to_string()))?; - while let Some(item) = stream.next().await { - let fetch = item.map_err(|error| CoreError::Imap(error.to_string()))?; - let Some(uid) = fetch.uid.filter(|uid| requested.contains(uid)) else { - continue; + selective_fetch(session, &uid_set, &query, |uid, attributes| { + if !requested.contains(&uid) { + return; + } + let section_bytes = |path: &SectionPath| { + attributes.iter().find_map(|attribute| match attribute { + async_imap::imap_proto::AttributeValue::BodySection { + section: Some(section), + data: Some(data), + .. + } if section == path => Some(data.to_vec()), + _ => None, + }) }; let fetched = result.entry(uid).or_default(); for (section, numbers) in &paths { let body_path = SectionPath::Part(numbers.clone(), None); let mime_path = SectionPath::Part(numbers.clone(), Some(MessageSection::Mime)); - if let Some(body) = fetch.section(&body_path) { - // A duplicate response for the same UID should not duplicate - // a section in the public result. + if let Some(body) = section_bytes(&body_path) { if fetched.iter().any(|item| item.section == *section) { continue; } fetched.push(FetchedSection { section: section.clone(), - mime_header: fetch - .section(&mime_path) - .map(ToOwned::to_owned) - .unwrap_or_default(), - body: body.to_vec(), + mime_header: section_bytes(&mime_path).unwrap_or_default(), + body, }); } } - } + }) + .await?; Ok(result .into_iter() .map(|(uid, sections)| FetchedMessageSections { uid, sections }) diff --git a/crates/flectar-mail-core/src/sync/engine.rs b/crates/flectar-mail-core/src/sync/engine.rs index 9488563..947bb90 100644 --- a/crates/flectar-mail-core/src/sync/engine.rs +++ b/crates/flectar-mail-core/src/sync/engine.rs @@ -342,8 +342,7 @@ async fn run_body_fetcher( } } } - let Some(s) = session.as_mut() else { break }; - match fetch_bodies_batch(&ctx, &config, s, &ids).await { + match fetch_bodies_batch(&ctx, &config, &mut session, &ids).await { Ok(()) => { fetched = true; break; @@ -355,9 +354,7 @@ async fn run_body_fetcher( ); // The session may be broken; drop it so the retry (and any // later request) reconnects. - if let Some(s) = session.take() { - imap::logout(s).await; - } + drop(session.take()); } } } @@ -423,7 +420,7 @@ async fn fetch_attachment_bytes( async fn fetch_bodies_batch( ctx: &SyncCtx, config: &AccountConfig, - session: &mut Session, + session: &mut Option, ids: &[i64], ) -> Result<()> { use std::collections::HashMap; @@ -462,7 +459,8 @@ async fn fetch_bodies_batch( reset_bodies_none(ctx, items.iter().map(|(_, m)| *m)).await; continue; }; - select_folder_for_remote_read(ctx, session, &folder).await?; + select_folder_for_remote_read(ctx, session.as_mut().ok_or(CoreError::Offline)?, &folder) + .await?; items.sort_unstable_by_key(|(uid, _)| *uid); for (uid, message_id) in items { if !remote_location_is_current(ctx, message_id, folder_id, i64::from(uid)).await? { @@ -868,10 +866,13 @@ async fn run_actor( imap::IdleOutcome::Command(SyncCmd::FetchBody { message_id, }) => { - if let Some(ref mut s) = session - && let Err(e) = - fetch_one_body(&ctx, &config, s, message_id) - .await + if let Err(e) = fetch_one_body( + &ctx, + &config, + &mut session, + message_id, + ) + .await { tracing::warn!("priority body fetch failed: {e}"); } @@ -947,8 +948,10 @@ async fn run_actor( break; } Ok(Some(SyncCmd::FetchBody { message_id })) => { - if let Some(ref mut s) = session { - if let Err(e) = fetch_one_body(&ctx, &config, s, message_id).await { + if session.is_some() { + if let Err(e) = + fetch_one_body(&ctx, &config, &mut session, message_id).await + { tracing::warn!("priority body fetch failed: {e}"); deadline = tokio::time::Instant::now(); } @@ -2188,9 +2191,10 @@ enum BodyChunkError { fn classify_body_chunk_error(error: CoreError) -> BodyChunkError { match error { - error @ (CoreError::Imap(_) | CoreError::Network(_) | CoreError::Offline) => { - BodyChunkError::Reconnect(error) - } + error @ (CoreError::Imap(_) + | CoreError::ImapParse(_) + | CoreError::Network(_) + | CoreError::Offline) => BodyChunkError::Reconnect(error), error => BodyChunkError::Fatal(error), } } @@ -2625,6 +2629,23 @@ async fn body_worker( "body worker: reconnect after batch split failed" ), } + } else if matches!(error, CoreError::ImapParse(_)) { + let failures = retry + .items + .iter() + .map(|(message_id, _)| (*message_id, error.to_string())) + .collect(); + record_content_failure_details(&ctx, failures).await?; + skip.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .extend(retry.items.iter().map(|(message_id, _)| *message_id)); + drop(session); + if queue.lock().await.is_empty() { + return Ok(()); + } + session = connect(&ctx, &config).await?; + selected = None; + continue; } else { queue.lock().await.push_front(retry); } @@ -2965,7 +2986,7 @@ async fn revalidate_background_fetches( async fn fetch_one_body( ctx: &SyncCtx, config: &AccountConfig, - session: &mut Session, + session: &mut Option, message_id: i64, ) -> Result<()> { let row = ctx @@ -2984,7 +3005,8 @@ async fn fetch_one_body( .read(move |conn| repo::folders::get(conn, folder_id)) .await? .ok_or_else(|| CoreError::NotFound("folder".into()))?; - select_folder_for_remote_read(ctx, session, &folder).await?; + select_folder_for_remote_read(ctx, session.as_mut().ok_or(CoreError::Offline)?, &folder) + .await?; if !remote_location_is_current(ctx, message_id, folder_id, uid).await? { reset_bodies_none(ctx, std::iter::once(message_id)).await; return Ok(()); @@ -2995,7 +3017,7 @@ async fn fetch_one_body( async fn store_one_body( ctx: &SyncCtx, config: &AccountConfig, - session: &mut Session, + session: &mut Option, message_id: i64, folder_id: i64, uid: u32, @@ -3045,7 +3067,7 @@ fn decode_selective_content( let mut calendar_parts = Vec::new(); for planned in &item.plan.text_sections { let section = fetched_by_section.get(&planned.section).ok_or_else(|| { - CoreError::Imap(format!( + CoreError::Mime(format!( "server omitted planned MIME section {}", planned.section )) @@ -3079,7 +3101,7 @@ fn decode_selective_content( async fn fetch_selective_content( ctx: &SyncCtx, config: &AccountConfig, - session: &mut Session, + session: &mut Option, message_id: i64, folder_id: i64, uid: u32, @@ -3105,7 +3127,28 @@ async fn fetch_selective_content( "message remote location changed before MIME-plan fetch".into(), )); } - let plans = imap::fetch_mime_plans_batch(session, &[uid]).await?; + let plans = match imap::fetch_mime_plans_batch( + session.as_mut().ok_or(CoreError::Offline)?, + &[uid], + ) + .await + { + Ok(plans) => plans, + Err(error @ CoreError::ImapParse(_)) if allow_open_fallback => { + tracing::warn!(message_id, error = %error, "MIME plan unavailable; retrying full message on a fresh session"); + reconnect_body_session(ctx, config, session, folder_id).await?; + return fetch_full_open_fallback( + ctx, + config, + session.as_mut().ok_or(CoreError::Offline)?, + message_id, + folder_id, + uid, + ) + .await; + } + Err(error) => return Err(error), + }; plan = plans .into_iter() .find(|fetched| fetched.uid == uid) @@ -3134,8 +3177,15 @@ async fn fetch_selective_content( let Some(plan) = plan else { if allow_open_fallback { - return fetch_full_open_fallback(ctx, config, session, message_id, folder_id, uid) - .await; + return fetch_full_open_fallback( + ctx, + config, + session.as_mut().ok_or(CoreError::Offline)?, + message_id, + folder_id, + uid, + ) + .await; } return Err(CoreError::Mime( "server did not provide a usable BODYSTRUCTURE".into(), @@ -3151,7 +3201,12 @@ async fn fetch_selective_content( )); } let section_ids = plan.text_section_ids(); - let fetched = imap::fetch_content_sections(session, uid, §ion_ids).await?; + let fetched = imap::fetch_content_sections( + session.as_mut().ok_or(CoreError::Offline)?, + uid, + §ion_ids, + ) + .await?; decode_selective_content( PlannedBodyFetch { message_id, @@ -3164,14 +3219,24 @@ async fn fetch_selective_content( .await; let content = match selective { Ok(content) => content, - Err(error) if allow_open_fallback => { + Err(error @ (CoreError::ImapParse(_) | CoreError::Mime(_))) if allow_open_fallback => { tracing::warn!( message_id, error = %error, "selective content unavailable; using explicit-open full MIME fallback" ); - return fetch_full_open_fallback(ctx, config, session, message_id, folder_id, uid) - .await; + if matches!(error, CoreError::ImapParse(_)) { + reconnect_body_session(ctx, config, session, folder_id).await?; + } + return fetch_full_open_fallback( + ctx, + config, + session.as_mut().ok_or(CoreError::Offline)?, + message_id, + folder_id, + uid, + ) + .await; } Err(error) => return Err(error), }; @@ -3185,6 +3250,26 @@ async fn fetch_selective_content( .map(|_| ()) } +/// A parser failure can leave unread response bytes. Close the old socket +/// without LOGOUT, and verify the UID namespace before using its replacement. +async fn reconnect_body_session( + ctx: &SyncCtx, + config: &AccountConfig, + session: &mut Option, + folder_id: i64, +) -> Result<()> { + drop(session.take()); + let folder = ctx + .db + .read(move |conn| repo::folders::get(conn, folder_id)) + .await? + .ok_or_else(|| CoreError::NotFound("folder".into()))?; + let mut fresh = connect(ctx, config).await?; + select_folder_for_remote_read(ctx, &mut fresh, &folder).await?; + *session = Some(fresh); + Ok(()) +} + async fn fetch_full_open_fallback( ctx: &SyncCtx, config: &AccountConfig, @@ -3401,3 +3486,7 @@ async fn persist_body( } Ok(()) } + +#[cfg(all(test, not(any(target_os = "android", target_os = "ios"))))] +#[path = "mime_recovery_tests.rs"] +mod mime_recovery_tests; diff --git a/crates/flectar-mail-core/src/sync/mime_recovery_tests.rs b/crates/flectar-mail-core/src/sync/mime_recovery_tests.rs new file mode 100644 index 0000000..f46a662 --- /dev/null +++ b/crates/flectar-mail-core/src/sync/mime_recovery_tests.rs @@ -0,0 +1,474 @@ +//! Local TLS transcripts exercise the real async-imap decoder and sync paths. +use super::*; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use std::sync::Mutex; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +const CERT: &str = include_str!("../../tests/fixtures/tls/server.pem"); +const KEY: &str = include_str!("../../tests/fixtures/tls/server-key.pem"); +const TEXT_PLAN: &str = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"UTF-8\") NIL NIL \"7BIT\" 6 1)"; + +#[derive(Clone, Copy)] +enum Behavior { + Broken, + Denied, + Reauth, + Reset, + GlobalLegacy, + GlobalExtended, +} + +struct Fixture { + ctx: SyncCtx, + config: AccountConfig, + folder: Folder, + ids: Vec, + commands: Arc>>, + server: tokio::task::JoinHandle<()>, + _dir: tempfile::TempDir, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} +fn raw(uid: u32) -> String { + format!( + "Message-ID: \r\nFrom: sender@example.test\r\nTo: reader@example.test\r\nSubject: Message {uid}\r\nDate: Tue, 01 Sep 2026 12:00:00 +0000\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nBody {uid}" + ) +} +fn uids(set: &str) -> Vec { + set.split(',') + .flat_map(|part| { + let mut ends = part.split(':').map(|n| n.parse::().unwrap()); + let first = ends.next().unwrap(); + first..=ends.next().unwrap_or(first) + }) + .collect() +} +async fn fixture(behavior: Behavior) -> (Fixture, Option) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from_pem_slice(CERT.as_bytes()).unwrap()], + PrivateKeyDer::from_pem_slice(KEY.as_bytes()).unwrap(), + ) + .unwrap(); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls)); + let commands = Arc::new(Mutex::new(Vec::new())); + let log = commands.clone(); + let server = tokio::spawn(async move { + for connection in 0.. { + let (tcp, _) = listener.accept().await.unwrap(); + let mut stream = BufReader::new(acceptor.accept(tcp).await.unwrap()); + stream.write_all(b"* OK fixture ready\r\n").await.unwrap(); + loop { + let mut line = String::new(); + if stream.read_line(&mut line).await.unwrap_or(0) == 0 { + break; + } + log.lock().unwrap().push((connection, line.clone())); + let parts: Vec<_> = line.split_whitespace().collect(); + let tag = parts[0]; + let mut response = String::new(); + match parts[1] { + "LOGIN" if connection > 0 && matches!(behavior, Behavior::Reauth) => { + stream + .write_all( + format!("{tag} NO [AUTHENTICATIONFAILED] denied\r\n").as_bytes(), + ) + .await + .unwrap(); + continue; + } + "LOGIN" => {} + "SELECT" => { + let validity = if connection > 0 && matches!(behavior, Behavior::Reset) { + 8 + } else { + 7 + }; + response = + format!("* 3 EXISTS\r\n* OK [UIDVALIDITY {validity}] namespace\r\n"); + } + "UID" => { + assert_eq!(parts[2], "FETCH"); + let requested = uids(parts[3]); + if line.contains("BODYSTRUCTURE") && matches!(behavior, Behavior::Denied) { + stream + .write_all(format!("{tag} NO [NOPERM] denied\r\n").as_bytes()) + .await + .unwrap(); + continue; + } + for uid in requested { + if line.contains("BODYSTRUCTURE") { + if uid == 2 + && matches!( + behavior, + Behavior::GlobalLegacy | Behavior::GlobalExtended + ) + { + let plan = if matches!(behavior, Behavior::GlobalLegacy) { + "(\"MESSAGE\" \"GLOBAL\" NIL NIL NIL \"7BIT\" 123)" + .to_owned() + } else { + format!( + "(\"MESSAGE\" \"GLOBAL\" NIL NIL NIL \"7BIT\" 123 (NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL) {TEXT_PLAN} 1)" + ) + }; + response.push_str(&format!( + "* {uid} FETCH (UID {uid} BODYSTRUCTURE {plan})\r\n" + )); + continue; + } + if uid == 2 { + response + .push_str("* 2 FETCH (UID 2 BODYSTRUCTURE (BOGUS))\r\n"); + break; + } + response.push_str(&format!( + "* {uid} FETCH (UID {uid} BODYSTRUCTURE {TEXT_PLAN})\r\n" + )); + } else if line.contains("HEADER.FIELDS") { + let header = raw(uid); + response.push_str(&format!("* {uid} FETCH (UID {uid} FLAGS () BODY[HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID CONTENT-TYPE)] {{{}}}\r\n{header})\r\n", header.len())); + } else if line.contains("BODY.PEEK[]") { + let message = raw(uid); + response.push_str(&format!( + "* {uid} FETCH (UID {uid} BODY[] {{{}}}\r\n{message})\r\n", + message.len() + )); + } else { + assert!(line.contains("BODY.PEEK[1]"), "unexpected fetch: {line}"); + let mime = "Content-Type: text/plain; charset=utf-8\r\n\r\n"; + let body = format!("Body {uid}"); + response.push_str(&format!("* {uid} FETCH (UID {uid} BODY[1.MIME] {{{}}}\r\n{mime} BODY[1] {{{}}}\r\n{body})\r\n", mime.len(), body.len())); + } + } + } + "LOGOUT" => { + response.push_str("* BYE closing\r\n"); + } + other => panic!("unexpected command: {other}"), + } + response.push_str(&format!("{tag} OK complete\r\n")); + stream.write_all(response.as_bytes()).await.unwrap(); + if parts[1] == "LOGOUT" { + break; + } + } + } + }); + let dir = tempfile::tempdir().unwrap(); + let paths = Arc::new(Paths::for_tests(dir.path())); + let db = Db::open(&dir.path().join("mail.db")).unwrap(); + let calendar_db = Db::open_calendar(&dir.path().join("calendar.db")).unwrap(); + let credentials: CredentialStoreHandle = Arc::new( + credentials::DevelopmentFileCredentialStore::new(dir.path().join("credentials.json")), + ); + let tokens = TokenProvider::new( + credentials.clone(), + Arc::new(crate::oauth::redirect::LoopbackRedirectBroker::default()), + ); + let ctx = SyncCtx { + db, + calendar_db, + bus: EventBus::new(), + paths, + tokens, + credentials, + }; + let (mut config, folder) = ctx + .db + .write(move |conn| { + let id = repo::accounts::insert( + conn, + &repo::accounts::NewAccount { + email: "reader@example.test", + display_name: None, + avatar_url: None, + provider: Provider::Imap, + auth_kind: AuthKind::Password, + mail_protocol: MailProtocol::Imap, + username: "reader@example.test", + jmap_url: "", + jmap_account_id: None, + imap_host: "127.0.0.1", + imap_port: port, + smtp_host: "127.0.0.1", + smtp_port: 465, + }, + )?; + let folder_id = + repo::folders::upsert(conn, id, "INBOX", Some("/"), Some(roles::INBOX))?; + repo::folders::set_uid_state(conn, folder_id, Some(7), Some(4), None)?; + Ok(( + repo::accounts::get_config(conn, id)?.unwrap(), + repo::folders::get(conn, folder_id)?.unwrap(), + )) + }) + .await + .unwrap(); + config.settings.connection = MailConnectionSettings { + imap_security: ConnectionSecurity::Tls, + smtp_security: ConnectionSecurity::Tls, + trusted_certificate_pem: CERT.into(), + }; + credentials::store_async( + ctx.credentials.clone(), + config.id, + Slot::Password, + "fixture-secret".into(), + ) + .await + .unwrap(); + let mut session = connect(&ctx, &config).await.unwrap(); + select_folder_for_remote_read(&ctx, &mut session, &folder) + .await + .unwrap(); + let headers = imap::fetch_headers(&mut session, "1:3").await.unwrap(); + store_headers(&ctx, &config, &folder, headers, None) + .await + .unwrap(); + let folder_id = folder.id; + let ids = ctx + .db + .read(move |conn| { + (1..=3) + .map(|uid| { + Ok(repo::messages::by_folder_uid(conn, folder_id, uid)? + .unwrap() + .id) + }) + .collect::>>() + }) + .await + .unwrap(); + ( + Fixture { + ctx, + config, + folder, + ids, + commands, + server, + _dir: dir, + }, + Some(session), + ) +} +async fn open_second(f: &Fixture, session: &mut Option) -> Result<()> { + let id = f.ids[1]; + f.ctx + .db + .write(move |conn| { + conn.execute( + "UPDATE messages SET body_state='fetching' WHERE id=?1", + [id], + )?; + Ok(()) + }) + .await + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(10), + fetch_one_body(&f.ctx, &f.config, session, id), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn headers_survive_parse_failure_and_open_reconnects_without_marking_seen() { + let (f, mut session) = fixture(Behavior::Broken).await; + open_second(&f, &mut session).await.unwrap(); + let ids = f.ids.clone(); + f.ctx + .db + .read(move |conn| { + for id in &ids { + assert!(repo::messages::get_row(conn, *id)?.is_some()); + } + let row = repo::messages::get_row(conn, ids[1])?.unwrap(); + assert_eq!(row.body_state, "cached"); + assert!(!row.is_read); + assert!(row.raw_path.is_some()); + Ok(()) + }) + .await + .unwrap(); + let commands = f.commands.lock().unwrap(); + assert!( + commands + .iter() + .any(|(c, line)| *c == 0 && line.contains("BODYSTRUCTURE")) + ); + assert!( + commands + .iter() + .any(|(c, line)| *c == 1 && line.contains("SELECT")) + ); + assert!( + commands + .iter() + .any(|(c, line)| *c == 1 && line.contains("BODY.PEEK[]")) + ); + assert!( + !commands + .iter() + .any(|(_, line)| line.contains("STORE") || line.contains("LOGOUT")) + ); +} + +#[tokio::test] +async fn background_isolates_bad_uid_and_caches_both_siblings() { + let (f, session) = fixture(Behavior::Broken).await; + drop(session); + let items = f + .ids + .iter() + .enumerate() + .map(|(i, id)| (*id, i as i64 + 1)) + .collect(); + let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::from([ + BodyChunk { + folder_id: f.folder.id, + folder_name: "INBOX".into(), + uid_validity: Some(7), + items, + }, + ]))); + let skip = Arc::new(Mutex::new(Default::default())); + let persisted = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let (_settings, rx) = watch::channel(f.config.settings.clone()); + tokio::time::timeout( + std::time::Duration::from_secs(10), + body_worker( + f.ctx.clone(), + f.config.clone(), + queue, + skip, + persisted.clone(), + rx, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(persisted.load(std::sync::atomic::Ordering::Relaxed), 2); + let ids = f.ids.clone(); + f.ctx + .db + .read(move |conn| { + for i in [0, 2] { + assert_eq!( + repo::messages::get_row(conn, ids[i])?.unwrap().body_state, + "cached" + ); + } + assert_eq!( + repo::messages::get_row(conn, ids[1])?.unwrap().body_state, + "none" + ); + assert!( + conn.query_row( + "SELECT count(*) FROM sync_failures WHERE message_id=?1", + [ids[1]], + |r| r.get::<_, i64>(0) + )? > 0 + ); + Ok(()) + }) + .await + .unwrap(); + assert!( + !f.commands + .lock() + .unwrap() + .iter() + .any(|(_, line)| line.contains("BODY.PEEK[]")) + ); +} + +#[tokio::test] +async fn rejected_fetch_does_not_trigger_compatibility_fallback() { + let (f, mut session) = fixture(Behavior::Denied).await; + let result = open_second(&f, &mut session).await; + assert!( + matches!(result, Err(CoreError::Imap(_))), + "{result:?}: {:?}", + f.commands.lock().unwrap() + ); + assert!( + f.commands + .lock() + .unwrap() + .iter() + .all(|(c, line)| *c == 0 && !line.contains("BODY.PEEK[]")) + ); +} + +#[tokio::test] +async fn reconnect_authentication_failure_is_returned_without_fetching_body() { + let (f, mut session) = fixture(Behavior::Reauth).await; + assert!(matches!( + open_second(&f, &mut session).await, + Err(CoreError::Auth(_)) + )); + assert!(session.is_none()); + assert!( + !f.commands + .lock() + .unwrap() + .iter() + .any(|(_, line)| line.contains("BODY.PEEK[]")) + ); +} + +#[tokio::test] +async fn changed_uidvalidity_prevents_full_message_fetch() { + let (f, mut session) = fixture(Behavior::Reset).await; + let result = open_second(&f, &mut session).await; + assert!( + matches!(result, Err(CoreError::Imap(_))), + "{result:?}: {:?}", + f.commands.lock().unwrap() + ); + assert!(session.is_none()); + assert!( + !f.commands + .lock() + .unwrap() + .iter() + .any(|(_, line)| line.contains("BODY.PEEK[]")) + ); +} + +// Both legal message/global representations must remain readable even when +// the pinned IMAP parser cannot build a selective plan for one of them. +#[tokio::test] +async fn legal_message_global_variants_remain_readable() { + for behavior in [Behavior::GlobalLegacy, Behavior::GlobalExtended] { + let (f, mut session) = fixture(behavior).await; + open_second(&f, &mut session).await.unwrap(); + let id = f.ids[1]; + f.ctx + .db + .read(move |conn| { + assert_eq!( + repo::messages::get_row(conn, id)?.unwrap().body_state, + "cached" + ); + Ok(()) + }) + .await + .unwrap(); + } +} From c05619b30b564962375b989a29bb05ea9dfe02ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Sat, 12 Sep 2026 21:10:31 +0000 Subject: [PATCH 4/6] fix: account removal --- src/account_controller.rs | 104 ++++++++++- src/account_removal_tests.rs | 153 ++++++++++++++++ src/data_controller.rs | 1 + src/mail.rs | 160 ++++++++++++++++- src/mail_view_model.rs | 6 + src/mail_work.rs | 14 ++ src/main.rs | 339 +++++++++++++++++++++++++++++------ src/window_controller.rs | 2 + 8 files changed, 718 insertions(+), 61 deletions(-) create mode 100644 src/account_removal_tests.rs diff --git a/src/account_controller.rs b/src/account_controller.rs index 5023085..ddb14af 100644 --- a/src/account_controller.rs +++ b/src/account_controller.rs @@ -2,6 +2,107 @@ use super::*; +pub(super) struct AccountRemovalUpdate { + pub account_id: i64, + pub removed: bool, + pub scope: String, + pub query: String, + pub metadata: Option, + pub page: Option, + pub calendar_month: NaiveDate, + pub calendar_events: Option>, + pub calendar_sources: Option>, +} + +pub(super) fn account_was_removed( + account_id: i64, + result: &Result<(), String>, + snapshot: Option<&mail::AccountSnapshot>, +) -> bool { + result.is_ok() + || snapshot.is_some_and(|snapshot| { + !snapshot + .accounts + .iter() + .any(|account| account.id == account_id) + }) +} + +pub(super) fn snapshot_is_current(state: &Rc>, revision: u64) -> bool { + state + .borrow() + .core + .as_ref() + .is_some_and(|core| core.account_revision() == revision) +} + +pub(super) fn account_owns_scope(mailboxes: &[MailboxEntry], scope: &str, account_id: i64) -> bool { + mailboxes + .iter() + .any(|mailbox| mailbox.account_id == account_id && mailbox.scope == scope) +} + +/// Apply the local half of deletion before projecting any account or mail +/// models. Even if reloading fails after deletion, no deleted rows survive. +pub(super) fn reconcile_removed_account(state: &mut InboxState, account_id: i64) { + if account_owns_scope(&state.mailboxes, &state.scope, account_id) { + state.scope = "Unified Inbox".into(); + } + if state + .messages + .iter() + .any(|message| message.account_id == account_id && Some(message.id) == state.selected_id) + { + state.selected_id = None; + state.preview_closed = true; + state.remote_images_override_id = None; + } + state + .messages + .retain(|message| message.account_id != account_id); + state + .mailboxes + .retain(|mailbox| mailbox.account_id != account_id); + state + .connected_accounts + .retain(|account| account.id != account_id); + state + .account_configs + .retain(|config| config.id != account_id); + state + .calendar_connections + .retain(|connection| connection.account_id != account_id); + state.calendar_errors.remove(&account_id); + state.profile_avatar_images.remove(&account_id); + state.profile_avatar_missing.remove(&account_id); + state.profile_avatar_pending.remove(&account_id); + state.collapsed_folder_ids.retain(|id| { + state + .mailboxes + .iter() + .any(|mailbox| mailbox.folder_id == *id) + }); + state.checked_ids.clear(); + state.next_cursor = None; + state.page = 1; + state.total_count = state.messages.len(); + state.inbox_count = 0; + for mailbox in &mut state.unified_mailboxes { + mailbox.count.clear(); + } + if state.connected_accounts.is_empty() { + state.messages.clear(); + state.mailboxes.clear(); + state.unified_mailboxes.clear(); + state.labels.clear(); + state.scope = "Unified Inbox".into(); + state.query.clear(); + state.selected_id = None; + state.total_count = 0; + } + mail_work::invalidate(state); +} + pub(super) fn apply_connected_accounts( app: &AppWindow, accounts: &[Account], @@ -84,7 +185,8 @@ pub(super) fn refresh_connected_accounts(app: &AppWindow, state: &Rc().invoke_context_changed(); + app.global::() + .invoke_context_changed(); app.global::().invoke_context_changed(); } diff --git a/src/account_removal_tests.rs b/src/account_removal_tests.rs new file mode 100644 index 0000000..aab3fce --- /dev/null +++ b/src/account_removal_tests.rs @@ -0,0 +1,153 @@ +//! Account-removal transitions against the real Slint models. +use super::*; +use slint::platform::{ + Platform, WindowAdapter, + software_renderer::{MinimalSoftwareWindow, RepaintBufferType}, +}; + +struct Headless(Rc); +impl Platform for Headless { + fn create_window_adapter(&self) -> Result, slint::PlatformError> { + Ok(self.0.clone()) + } +} + +fn account(id: i64) -> Account { + Account { + id, + email: format!("account-{id}@example.test"), + display_name: Some(format!("Account {id}")), + avatar_url: None, + provider: Provider::Imap, + auth_kind: flectar_mail_core::models::AuthKind::Password, + mail_protocol: MailProtocol::Imap, + sync_state: "idle".into(), + sync_error: None, + } +} +fn config(account: &Account) -> AccountConfig { + AccountConfig { + id: account.id, + email: account.email.clone(), + display_name: account.display_name.clone(), + avatar_url: None, + provider: account.provider, + auth_kind: account.auth_kind, + mail_protocol: account.mail_protocol, + username: account.email.clone(), + jmap_url: String::new(), + jmap_account_id: None, + imap_host: "imap.example.test".into(), + imap_port: 993, + smtp_host: "smtp.example.test".into(), + smtp_port: 465, + settings: Default::default(), + } +} + +#[test] +fn account_removal_clears_deleted_models_and_preserves_surviving_selection() { + let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); + slint::platform::set_platform(Box::new(Headless(window))).unwrap(); + let app = AppWindow::new().unwrap(); + app.set_startup_ready(true); + let runtime = Rc::new( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(), + ); + let dir = tempfile::tempdir().unwrap(); + for removed in [2, 1] { + let (fav, _) = bounded_ui_channel(); + let (avatars, _) = bounded_ui_channel(); + let mut state = InboxState::empty( + None, + UiSender::new(fav, UiWake::new(app.as_weak(), |_| {})), + None, + UiSender::new(avatars, UiWake::new(app.as_weak(), |_| {})), + false, + false, + WarmStartCacheWriter::spawn(&runtime, dir.path().join(format!("warm-{removed}.json"))), + ); + state.using_core = true; + state.connected_accounts = vec![account(1), account(2)]; + state.account_configs = state.connected_accounts.iter().map(config).collect(); + state.messages = fixture_messages().into_iter().take(2).collect(); + for (i, message) in state.messages.iter_mut().enumerate() { + message.account_id = i as i64 + 1; + message.account = format!("Account {}", i + 1); + message.folder = "Inbox".into(); + } + state.mailboxes = fixture_mailboxes(&state.messages); + for mailbox in &mut state.mailboxes { + mailbox.account_id = if mailbox.context == "Account 1" { 1 } else { 2 }; + } + state.scope = "Account 1 / Inbox".into(); + state.selected_id = Some(state.messages[0].id); + state.rendered_id = state.selected_id; + state.preview_closed = false; + state.checked_ids = state.messages.iter().map(|message| message.id).collect(); + state.next_cursor = Some(ThreadCursor { + last_message_at: 1, + thread_id: 1, + }); + state.page = 3; + let first_id = state.messages[0].id; + let state = Rc::new(RefCell::new(state)); + mail_work::register(&app, &state, &runtime); + refresh_connected_accounts(&app, &state); + assert_eq!(app.get_connected_accounts().row_count(), 2); + let old_generation = mail_work::generation(&state.borrow()); + reconcile_removed_account(&mut state.borrow_mut(), removed); + { + let state = state.borrow(); + assert!( + state + .messages + .iter() + .all(|message| message.account_id != removed) + ); + assert!( + state + .mailboxes + .iter() + .all(|mailbox| mailbox.account_id != removed) + ); + assert!( + state + .account_configs + .iter() + .all(|account| account.id != removed) + ); + assert!(state.checked_ids.is_empty()); + assert!(state.next_cursor.is_none()); + assert_eq!(state.page, 1); + assert!(!mail_work::accepts_background(&state, old_generation)); + if removed == 2 { + assert_eq!(state.scope, "Account 1 / Inbox"); + assert_eq!(state.selected_id, Some(first_id)); + } else { + assert_eq!(state.scope, "Unified Inbox"); + assert_eq!(state.selected_id, None); + assert!(state.preview_closed); + } + } + refresh_connected_accounts(&app, &state); + assert_eq!(app.get_connected_accounts().row_count(), 1); + assert_eq!(app.get_compose_account_id(), (3 - removed) as i32); + // Removing the final account must empty both the Rust state and the + // models that drive onboarding, compose and the mail workspace. + reconcile_removed_account(&mut state.borrow_mut(), 3 - removed); + refresh_connected_accounts(&app, &state); + render_current(&app, &state, &runtime).unwrap(); + assert_eq!(app.get_connected_accounts().row_count(), 0); + assert_eq!(app.get_compose_account_id(), -1); + assert_eq!(state.borrow().email_rows.row_count(), 0); + assert!(state.borrow().mailboxes.is_empty()); + assert!(state.borrow().unified_mailboxes.is_empty()); + assert!(state.borrow().selected_id.is_none()); + assert!(state.borrow().rendered_id.is_none()); + assert!(!app.global::().get_available()); + } +} diff --git a/src/data_controller.rs b/src/data_controller.rs index c09997e..308468c 100644 --- a/src/data_controller.rs +++ b/src/data_controller.rs @@ -220,6 +220,7 @@ pub(super) fn register_data_management_callbacks( accounts: None, calendar_connections: None, calendar_error: None, + account_removal: None, clear_account_form: false, finishes_account_setup: false, finishes_oauth: false, diff --git a/src/mail.rs b/src/mail.rs index b35b5b4..a85970c 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -153,6 +153,7 @@ pub struct ComposeSource { #[derive(Clone, Debug)] pub struct MailPage { + pub account_revision: u64, pub messages: Vec, pub labels: Vec