//! Account configuration transfer, verified database export, and local-data //! reset callback wiring. //! //! These operations share the same sensitive-data boundary and platform file //! picker constraints. Keeping them out of the composition root makes the //! credential-exclusion and destructive-reset behavior reviewable together. use super::*; use chrono::Utc; use flectar_mail_core::accounts::{backup as account_backup, mailbox_key}; use flectar_mail_core::models::{ MIN_WORKSPACE_LIST_PANE_WIDTH, MailExport, PortableAccountConfig, Settings, normalized_workspace_list_pane_width, }; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; const MAX_ACCOUNT_BACKUP_BYTES: u64 = 1024 * 1024; const STORAGE_CATEGORY_COUNT: usize = 5; const STORAGE_MAIL: usize = 0; const STORAGE_ATTACHMENTS: usize = 1; const STORAGE_FILES: usize = 2; const STORAGE_DATABASES: usize = 3; const STORAGE_OTHER: usize = 4; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PortableMailProfile { name: String, color: String, #[serde(default)] account_emails: Vec, } fn portable_account_colors( backup: &serde_json::Value, imported_emails: &HashSet, ) -> Result>, String> { let Some(value) = backup .get("preferences") .and_then(|preferences| preferences.get("accountColors")) else { return Ok(None); }; let colors = serde_json::from_value::>(value.clone()) .map_err(|error| format!("invalid account color data: {error}"))?; if colors.len() > 100 { return Err("backup contains more than 100 account colors".into()); } let mut normalized = HashMap::new(); for (email, color) in colors { let email = email.trim().to_ascii_lowercase(); if !imported_emails.contains(&email) { return Err(format!( "account color references an account outside the backup: {email:?}" )); } let valid_color = color.len() == 7 && color.starts_with('#') && color[1..].bytes().all(|byte| byte.is_ascii_hexdigit()); if !valid_color { return Err(format!("account {email:?} has an invalid color")); } if normalized.insert(email.clone(), color).is_some() { return Err(format!("backup contains duplicate account color {email:?}")); } } Ok(Some(normalized)) } fn portable_mail_profiles( backup: &serde_json::Value, imported_emails: &HashSet, ) -> Result>, String> { let Some(value) = backup .get("preferences") .and_then(|preferences| preferences.get("mailProfiles")) else { return Ok(None); }; let profiles = serde_json::from_value::>(value.clone()) .map_err(|error| format!("invalid profile data: {error}"))?; if profiles.len() > 50 { return Err("backup contains more than 50 profiles".into()); } let mut assigned_emails = HashSet::new(); let mut profile_names = HashSet::new(); for profile in &profiles { let profile_name = profile.name.trim(); if profile_name.is_empty() || profile_name.chars().count() > 48 || profile_name.chars().any(char::is_control) { return Err("backup contains an invalid profile name".into()); } let valid_color = profile.color.len() == 7 && profile.color.starts_with('#') && profile.color[1..] .bytes() .all(|byte| byte.is_ascii_hexdigit()); if !valid_color { return Err(format!("profile {profile_name:?} has an invalid color")); } if !profile_names.insert(profile_name.to_ascii_lowercase()) { return Err(format!( "backup contains more than one profile named {:?}", profile.name )); } if profile.account_emails.len() > 100 { return Err(format!( "profile {:?} contains more than 100 accounts", profile.name )); } for email in &profile.account_emails { let email = email.trim().to_ascii_lowercase(); if !imported_emails.contains(&email) { return Err(format!( "profile {:?} references an account outside the backup", profile.name )); } if !assigned_emails.insert(email.clone()) { return Err(format!( "account {email:?} is assigned to more than one profile" )); } } } Ok(Some(profiles)) } #[derive(Debug, Default, PartialEq, Eq)] struct StorageScan { bytes: [u64; STORAGE_CATEGORY_COUNT], unreadable_entries: usize, } #[derive(Clone, Copy)] enum StorageRoot { Data, Cache, } fn storage_category(root: StorageRoot, relative: &std::path::Path) -> usize { use std::path::Component; let first = relative.components().find_map(|component| match component { Component::Normal(value) => value.to_str(), _ => None, }); match first { Some("mail") => STORAGE_MAIL, Some("attachments" | "draft_attachments") => STORAGE_ATTACHMENTS, Some("files" | "file_transfers") => STORAGE_FILES, _ if matches!(root, StorageRoot::Data) && relative .parent() .is_some_and(|parent| parent.as_os_str().is_empty()) && relative .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| { ["flectar-mail.db", "flectar-calendar.db", "flectar-files.db"] .iter() .any(|database| { name == *database || name.strip_prefix(database).is_some_and(|suffix| { matches!(suffix, "-wal" | "-shm" | "-journal") }) }) }) => { STORAGE_DATABASES } _ => STORAGE_OTHER, } } fn scan_storage_tree( root: &std::path::Path, kind: StorageRoot, skipped_root: Option<&std::path::Path>, generation: &AtomicU64, ticket: u64, scan: &mut StorageScan, ) -> bool { let mut directories = vec![root.to_path_buf()]; let mut visited = 0usize; while let Some(directory) = directories.pop() { if generation.load(Ordering::Acquire) != ticket { return false; } let entries = match std::fs::read_dir(&directory) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(_) => { scan.unreadable_entries += 1; continue; } }; for entry in entries { visited += 1; if visited.is_multiple_of(256) { if generation.load(Ordering::Acquire) != ticket { return false; } std::thread::yield_now(); } let entry = match entry { Ok(entry) => entry, Err(_) => { scan.unreadable_entries += 1; continue; } }; let path = entry.path(); if skipped_root.is_some_and(|skipped| path == skipped) { continue; } let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(_) => { scan.unreadable_entries += 1; continue; } }; if file_type.is_dir() { directories.push(path); } else if file_type.is_file() { match entry.metadata() { Ok(metadata) => { let relative = path.strip_prefix(root).unwrap_or(&path); let category = storage_category(kind, relative); scan.bytes[category] = scan.bytes[category].saturating_add(metadata.len()); } Err(_) => scan.unreadable_entries += 1, } } } } generation.load(Ordering::Acquire) == ticket } fn scan_storage(paths: &Paths, generation: &AtomicU64, ticket: u64) -> Option { let mut scan = StorageScan::default(); let nested_cache = (paths.cache_dir != paths.data_dir && paths.cache_dir.starts_with(&paths.data_dir)) .then_some(paths.cache_dir.as_path()); if !scan_storage_tree( &paths.data_dir, StorageRoot::Data, nested_cache, generation, ticket, &mut scan, ) { return None; } if paths.cache_dir != paths.data_dir && !scan_storage_tree( &paths.cache_dir, StorageRoot::Cache, paths .data_dir .starts_with(&paths.cache_dir) .then_some(paths.data_dir.as_path()), generation, ticket, &mut scan, ) { return None; } Some(scan) } pub(super) fn format_storage_size(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = KB * 1024.0; const GB: f64 = MB * 1024.0; const TB: f64 = GB * 1024.0; let bytes = bytes as f64; if bytes < KB { format!("{} B", bytes as u64) } else if bytes < MB { format!("{:.1} KB", bytes / KB) } else if bytes < GB { format!("{:.1} MB", bytes / MB) } else if bytes < TB { format!("{:.2} GB", bytes / GB) } else { format!("{:.2} TB", bytes / TB) } } fn storage_rows(scan: &StorageScan) -> Vec { let total = scan.bytes.iter().copied().sum::(); let colors = [ slint::Color::from_rgb_u8(0x3b, 0x82, 0xf6), slint::Color::from_rgb_u8(0xf5, 0x9e, 0x0b), slint::Color::from_rgb_u8(0x22, 0xc5, 0x5e), slint::Color::from_rgb_u8(0xa8, 0x55, 0xf7), slint::Color::from_rgb_u8(0x94, 0xa3, 0xb8), ]; let kinds = ["mail", "attachments", "files", "databases", "other"]; let mut offset = 0.0f32; scan.bytes .iter() .enumerate() .map(|(index, bytes)| { let fraction = if total == 0 { 0.0 } else { *bytes as f32 / total as f32 }; let row = StorageUsageRow { kind: kinds[index].into(), size: format_storage_size(*bytes).into(), fraction, offset, color: colors[index], }; offset += fraction; row }) .collect() } /// Outcome of restoring a Flectar account backup. pub(super) struct BackupRestore { pub(super) imported: usize, /// Accounts in the backup that this device already had. pub(super) already_present: usize, /// The backup carried preferences, which replaced the current ones. pub(super) preferences: bool, } /// Restores a Flectar Mail account backup: account settings, profiles, /// colors, and preferences. Existing accounts are kept unchanged. pub(super) fn restore_account_backup( app: &AppWindow, bytes: &[u8], core: &CoreMailSource, state: &Rc>, runtime: &tokio::runtime::Handle, ) -> Result { if bytes.len() as u64 > MAX_ACCOUNT_BACKUP_BYTES { return Err("backup is larger than the 1 MB safety limit".into()); } type Restored = (BackupRestore, Settings, Vec, Vec); let result = (|| -> Result { let backup: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| format!("invalid JSON: {error}"))?; if backup.get("format").and_then(|value| value.as_str()) != Some(account_backup::FORMAT) { return Err("this is not a Flectar Mail account backup".into()); } let Some(version) = backup.get("version").and_then(|value| value.as_u64()) else { return Err("this backup version is not supported".into()); }; if backup .get("includesCredentials") .and_then(|value| value.as_bool()) == Some(true) { return Err("backups containing credentials are not accepted".into()); } let configs = account_backup::parse_accounts( version, backup .get("accounts") .cloned() .ok_or_else(|| "backup has no accounts list".to_owned())?, )?; if configs.len() > 100 { return Err("backup contains more than 100 accounts".into()); } let imported_emails = configs .iter() .map(|config| config.email.trim().to_ascii_lowercase()) .collect::>(); let portable_profiles = portable_mail_profiles(&backup, &imported_emails)?; let has_portable_profiles = portable_profiles.is_some(); let portable_profiles = portable_profiles.unwrap_or_default(); let portable_colors = portable_account_colors(&backup, &imported_emails)?; let has_portable_colors = portable_colors.is_some(); let portable_colors = portable_colors.unwrap_or_default(); let mut settings = runtime.block_on(core.load_settings())?; let has_preferences = backup.get("preferences").is_some(); if let Some(preferences) = backup.get("preferences") { if let Some(theme) = preferences.get("theme").and_then(|value| value.as_str()) && matches!(theme, "system" | "carbon" | "snow" | "dark" | "light") { settings.theme = theme.to_owned(); } if let Some(enabled) = preferences .get("monochromeSidebarIcons") .and_then(|value| value.as_bool()) { settings.monochrome_sidebar_icons = enabled; } if let Some(icon) = preferences .get("appIcon") .and_then(|value| value.as_str()) .and_then(flectar_mail_core::models::AppIcon::parse) { settings.app_icon = icon; } if let Some(enabled) = preferences .get("showAvatars") .and_then(|value| value.as_bool()) { settings.show_avatars = enabled; } if let Some(enabled) = preferences .get("showTabs") .and_then(|value| value.as_bool()) { settings.show_tabs = enabled; } if let Some(enabled) = preferences .get("conversationView") .and_then(|value| value.as_bool()) { settings.conversation_view = enabled; } if let Some(frame) = preferences .get("windowFrame") .and_then(|value| value.as_str()) && matches!(frame, "system" | "integrated") { settings.window_frame = frame.to_owned(); } if let Some(layout) = preferences .get("workspaceLayout") .or_else(|| preferences.get("mailLayout")) .and_then(|value| value.as_str()) && matches!(layout, "default" | "minimal") { settings.workspace_layout = layout.to_owned(); } if let Some(preference) = preferences .get("messageAppearance") .and_then(|value| value.as_str()) .and_then(flectar_mail_core::models::MessageAppearance::parse) { settings.message_appearance = preference; } if let Some(width) = preferences .get("workspaceListPaneWidth") .and_then(|value| value.as_i64()) { settings.workspace_list_pane_width = normalized_workspace_list_pane_width(width); } if let Some(language) = preferences.get("language").and_then(|value| value.as_str()) && language.len() <= 32 { settings.language = language.to_owned(); } if let Some(format) = preferences .get("timeFormat") .and_then(|value| value.as_str()) .and_then(flectar_mail_core::models::TimeFormat::parse) { settings.time_format = format; } if let Some(week_start) = preferences .get("calendarWeekStart") .and_then(|value| value.as_str()) && matches!(week_start, "sunday" | "monday") { settings.calendar_week_start = week_start.to_owned(); } if let Some(enabled) = preferences .get("loadRemoteImages") .and_then(|value| value.as_bool()) { settings.load_remote_images = enabled; } if let Some(enabled) = preferences .get("markReadOnOpen") .and_then(|value| value.as_bool()) { settings.mark_read_on_open = enabled; } if let Some(enabled) = preferences .get("collectOutgoingContacts") .and_then(|value| value.as_bool()) { settings.collect_outgoing_contacts = enabled; } if let Some(enabled) = preferences .get("collectIncomingContacts") .and_then(|value| value.as_bool()) { settings.collect_incoming_contacts = enabled; } if let Some(enabled) = preferences .get("contactSuggestAllAccounts") .and_then(|value| value.as_bool()) { settings.contact_suggest_all_accounts = enabled; } if let Some(enabled) = preferences .get("suggestLearnedContacts") .and_then(|value| value.as_bool()) { settings.suggest_learned_contacts = enabled; } if let Some(enabled) = preferences .get("notificationsEnabled") .and_then(|value| value.as_bool()) { settings.notifications_enabled = enabled; } if let Some(scope) = preferences .get("notificationScope") .and_then(|value| value.as_str()) && matches!(scope, "important" | "all") { settings.notification_scope = scope.to_owned(); } if let Some(enabled) = preferences .get("soundEnabled") .and_then(|value| value.as_bool()) { settings.sound_enabled = enabled; } if let Some(minutes) = preferences .get("syncIntervalMinutes") .and_then(|value| value.as_i64()) && matches!(minutes, 1 | 5 | 15) { settings.sync_interval_minutes = minutes; } if let Some(enabled) = preferences .get("closeToTray") .and_then(|value| value.as_bool()) { settings.close_to_tray = enabled; } if let Some(enabled) = preferences .get("showAccountMarkers") .and_then(|value| value.as_bool()) { settings.show_account_badges = enabled; } } let backup_accounts = configs.len(); let imported = runtime.block_on(core.import_account_configs(configs))?; runtime.block_on(core.save_settings(settings.clone()))?; let accounts = runtime.block_on(core.load_accounts())?; let configs = runtime.block_on(core.load_account_configs())?; // A backup address can be another spelling of an account's Gmail // address, which the restore kept instead of adding it again. As // there, the account stored with the exact address comes first. let exact_ids = accounts .iter() .map(|account| (account.email.trim().to_ascii_lowercase(), account.id)) .collect::>(); let mut mailbox_ids = HashMap::new(); for account in &accounts { mailbox_ids .entry(mailbox_key(&account.email)) .and_modify(|id: &mut i64| *id = (*id).min(account.id)) .or_insert(account.id); } let account_for = |email: &str| { exact_ids .get(&email.trim().to_ascii_lowercase()) .or_else(|| mailbox_ids.get(&mailbox_key(email))) .copied() }; if has_portable_colors { for email in &imported_emails { if let Some(account_id) = account_for(email) { runtime.block_on(core.set_account_color(account_id, None))?; } } for (email, color) in portable_colors { if let Some(account_id) = account_for(&email) { runtime.block_on(core.set_account_color(account_id, Some(color)))?; } } } if has_portable_profiles { for email in &imported_emails { if let Some(account_id) = account_for(email) { runtime.block_on(core.assign_account_profile(account_id, None))?; } } } for profile in portable_profiles { let current = runtime.block_on(core.load_settings())?; let existing_id = current .mail_profiles .iter() .find(|candidate| candidate.name.eq_ignore_ascii_case(profile.name.trim())) .map(|candidate| candidate.id.clone()); let (_, saved) = runtime.block_on(core.save_mail_profile( existing_id, profile.name, profile.color, ))?; for email in profile.account_emails { if let Some(account_id) = account_for(&email) { runtime.block_on( core.assign_account_profile(account_id, Some(saved.id.clone())), )?; } } } settings = runtime.block_on(core.load_settings())?; let restore = BackupRestore { imported, already_present: backup_accounts.saturating_sub(imported), preferences: has_preferences, }; Ok((restore, settings, accounts, configs)) })(); let (restore, settings, accounts, configs) = result?; app.set_time_format(settings.time_format.as_str().into()); apply_language(app, &settings.language); state.borrow_mut().account_presentation = AccountPresentationSettings::from_settings(&settings); // Imported profiles can change which accounts the mail view shows. app.invoke_mail_profile_changed(); update_connected_accounts(app, state, runtime, accounts, configs); refresh_rows_only(app, state, runtime); app.set_theme_mode( match settings.theme.as_str() { "carbon" | "dark" => "dark", "snow" | "light" => "light", _ => "system", } .into(), ); app.set_monochrome_sidebar_icons(settings.monochrome_sidebar_icons); crate::app_icon::apply(app, settings.app_icon); app.global::() .set_preference(settings.message_appearance.as_str().into()); app.set_show_avatars(settings.show_avatars); app.set_show_tabs(settings.show_tabs); apply_mail_grouping(app, state, runtime, settings.mail_grouping()); app.set_show_account_markers(settings.show_account_badges); crate::window_chrome::apply_preference( app, crate::window_chrome::WindowFrame::parse(&settings.window_frame), ); app.set_workspace_layout(settings.workspace_layout.clone().into()); app.set_workspace_list_pane_width(normalized_workspace_list_pane_width( settings.workspace_list_pane_width, ) as f32); app.set_notifications_enabled(settings.notifications_enabled); app.set_notification_sound_enabled(settings.sound_enabled); app.set_notification_scope(settings.notification_scope.clone().into()); app.set_sync_interval_minutes(settings.sync_interval_minutes as i32); app.set_mark_read_on_open(settings.mark_read_on_open); app.set_collect_outgoing_contacts(settings.collect_outgoing_contacts); app.set_collect_incoming_contacts(settings.collect_incoming_contacts); app.set_contact_suggest_all_accounts(settings.contact_suggest_all_accounts); app.set_suggest_learned_contacts(settings.suggest_learned_contacts); state.borrow_mut().mark_read_on_open = settings.mark_read_on_open; app.set_remote_images_enabled(settings.load_remote_images && cfg!(feature = "remote-content")); #[cfg(not(any(mobile, feature = "flatpak")))] app.set_close_to_tray(settings.close_to_tray); #[cfg(any(mobile, feature = "flatpak"))] app.set_close_to_tray(false); Ok(restore) } /// Hands generated bytes to the document provider through a private staging /// file; the host copies it to the destination its picker offers. fn export_document_bytes( documents: std::sync::Arc, staging_root: PathBuf, bytes: Vec, name: String, dialog: documents::DocumentDialog, ) -> documents::DocumentFuture { Box::pin(async move { tokio::fs::create_dir_all(&staging_root) .await .map_err(|error| format!("creating {}: {error}", staging_root.display()))?; let staging = tempfile::tempdir_in(&staging_root) .map_err(|error| format!("creating staging in {}: {error}", staging_root.display()))?; let path = staging.path().join(&name); tokio::fs::write(&path, bytes) .await .map_err(|error| format!("writing {}: {error}", path.display()))?; documents.export_with(path, name, dialog).await }) } fn backup_dialog(app: &AppWindow, title: UiMessage) -> documents::DocumentDialog { documents::DocumentDialog { title: translated(app, &title).to_string(), filter: Some(( translated(app, &UiMessage::plain("Flectar Mail backup")).to_string(), &["json"], )), directory: None, } } #[cfg(desktop)] async fn pick_database_snapshot_path(title: String) -> Option { rfd::AsyncFileDialog::new() .set_title(title) .pick_folder() .await .map(|directory| { directory.path().join(format!( "flectar-mail-snapshot-{}", Local::now().format("%Y-%m-%d-%H%M%S") )) }) } /// Mobile hosts export single files from their app-private storage, so /// snapshots and mail exports are staged beside the data directory (Core /// refuses destinations inside the data and cache roots) and archived as one /// zip. #[cfg(mobile)] fn export_staging_root(paths: &Paths) -> Result { Ok(paths .data_dir .parent() .ok_or("the data directory has no parent for export staging")? .join("snapshot-staging")) } /// A zipped export waiting in staging for the export picker. #[cfg(mobile)] struct StagedExport { root: PathBuf, archive: PathBuf, name: String, /// Keeps the startup cleanup out of the staging root until it is removed. _staging: tokio::sync::OwnedMutexGuard<()>, } #[cfg(mobile)] impl StagedExport { async fn remove(self) { let _ = tokio::fs::remove_dir_all(&self.root).await; } } #[cfg(mobile)] async fn stage_database_snapshot( core: CoreMailSource, paths: Paths, staging: Arc>, ) -> Result { let staging = staging.lock_owned().await; let root = export_staging_root(&paths)?; // The root only ever holds this export, so clearing it also removes what // an interrupted export left behind. let _ = tokio::fs::remove_dir_all(&root).await; let staged = async { tokio::fs::create_dir_all(&root) .await .map_err(|error| format!("creating {}: {error}", root.display()))?; let name = format!( "flectar-mail-snapshot-{}", Local::now().format("%Y-%m-%d-%H%M%S") ); let directory = root.join(&name); core.create_database_snapshot(directory.clone()) .await .map_err(|error| error.to_string())?; let name = format!("{name}.zip"); let archive = root.join(&name); let archive_for_zip = archive.clone(); tokio::task::spawn_blocking(move || { archive_directory( &directory, &archive_for_zip, flectar_mail_core::files::MAX_TRANSFER as u64, ) }) .await .map_err(|error| error.to_string())??; Ok((archive, name)) } .await; match staged { Ok((archive, name)) => Ok(StagedExport { root, archive, name, _staging: staging, }), Err(error) => { let _ = tokio::fs::remove_dir_all(&root).await; Err(error) } } } /// Archives an export directory without compression: snapshots hold dense /// SQLite files plus already-compressed attachments, and mail travels as is. /// Symlinks are skipped so nothing outside the export enters the archive. /// Hosts transfer at most `limit` bytes, so a larger export fails before the /// archive is written. #[cfg(any(mobile, test))] fn archive_directory( directory: &std::path::Path, archive: &std::path::Path, limit: u64, ) -> Result<(), String> { /// Archive name, source path and, for files, the size. type Entry = (String, PathBuf, Option); fn collect( root: &std::path::Path, directory: &std::path::Path, entries: &mut Vec, ) -> Result<(), String> { let mut children = std::fs::read_dir(directory) .map_err(|error| format!("reading {}: {error}", directory.display()))? .collect::, _>>() .map_err(|error| error.to_string())?; children.sort_by_key(|entry| entry.file_name()); for entry in children { let path = entry.path(); let kind = entry.file_type().map_err(|error| error.to_string())?; let name = path .strip_prefix(root) .map_err(|error| error.to_string())? .to_string_lossy() .replace('\\', "/"); if kind.is_dir() { entries.push((format!("{name}/"), path.clone(), None)); collect(root, &path, entries)?; } else if kind.is_file() { let size = entry.metadata().map_err(|error| error.to_string())?.len(); entries.push((name, path, Some(size))); } } Ok(()) } let too_large = |size| { format!( "the export is {}, more than the {} this device can export", format_storage_size(size), format_storage_size(limit) ) }; let mut entries = Vec::new(); collect(directory, directory, &mut entries)?; let size = entries.iter().filter_map(|(_, _, size)| *size).sum::(); if size > limit { return Err(too_large(size)); } let file = std::fs::File::create(archive) .map_err(|error| format!("creating {}: {error}", archive.display()))?; let mut zip = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, path, size) in entries { let Some(size) = size else { zip.add_directory(name, options) .map_err(|error| error.to_string())?; continue; }; let mut source = std::fs::File::open(&path) .map_err(|error| format!("reading {}: {error}", path.display()))?; // Stores past 4 GiB need ZIP64 entries. zip.start_file(name, options.large_file(size >= u64::from(u32::MAX))) .map_err(|error| error.to_string())?; std::io::copy(&mut source, &mut zip).map_err(|error| error.to_string())?; } let size = zip .finish() .and_then(|file| Ok(file.metadata()?.len())) .map_err(|error| error.to_string())?; // Entry headers can still push the archive past the limit. if size > limit { return Err(too_large(size)); } Ok(()) } /// A folder name for an exported account that every platform accepts. fn export_folder_name(email: &str) -> String { email .chars() .map(|character| match character { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', character if character.is_control() => '_', character => character, }) .collect() } /// Export POP3 accounts as mbox files: one account directly into /// `destination`, several into one folder each, named by address. async fn export_device_mail( core: CoreMailSource, destination: PathBuf, accounts: Vec<(i64, String)>, ) -> Result { if let [(account_id, _)] = accounts.as_slice() { return core.export_account_mail(*account_id, destination).await; } tokio::fs::create_dir(&destination) .await .map_err(|error| format!("creating {}: {error}", destination.display()))?; let mut total = MailExport::default(); for (account_id, email) in accounts { let exported = core .export_account_mail(account_id, destination.join(export_folder_name(&email))) .await?; total.folders += exported.folders; total.messages += exported.messages; total.missing += exported.missing; } Ok(total) } #[cfg(mobile)] async fn stage_mail_export( core: CoreMailSource, paths: Paths, staging: Arc>, name: String, accounts: Vec<(i64, String)>, ) -> Result<(StagedExport, MailExport), String> { let staging = staging.lock_owned().await; let root = export_staging_root(&paths)?; let _ = tokio::fs::remove_dir_all(&root).await; let staged = async { tokio::fs::create_dir_all(&root) .await .map_err(|error| format!("creating {}: {error}", root.display()))?; let directory = root.join(&name); let exported = export_device_mail(core, directory.clone(), accounts).await?; let name = format!("{name}.zip"); let archive = root.join(&name); let archive_for_zip = archive.clone(); tokio::task::spawn_blocking(move || { archive_directory( &directory, &archive_for_zip, flectar_mail_core::files::MAX_TRANSFER as u64, ) }) .await .map_err(|error| error.to_string())??; Ok((archive, name, exported)) } .await; match staged { Ok((archive, name, exported)) => Ok(( StagedExport { root, archive, name, _staging: staging, }, exported, )), Err(error) => { let _ = tokio::fs::remove_dir_all(&root).await; Err(error) } } } fn mail_export_message(exported: &MailExport, location: &str) -> UiMessage { if exported.missing == 0 { UiMessage::detail("Mail exported to {}.", location) } else { UiMessage::detail( "Mail exported to {}. Some messages could not be found on this device or the mail server.", location, ) } } /// Mail of POP3 accounts, and local mail, exist only on this device, so they /// can be exported as mbox files before they, or all local data, are removed. fn register_mail_export( app: &AppWindow, state: &Rc>, runtime: &tokio::runtime::Handle, // Mobile hosts stage the export and hand it to the platform's picker. #[cfg_attr(desktop, allow(unused_variables))] documents: &std::sync::Arc< dyn documents::DocumentProvider, >, #[cfg_attr(desktop, allow(unused_variables))] paths: &Paths, #[cfg_attr(desktop, allow(unused_variables))] staging: &Arc>, ) { let app_weak = app.as_weak(); let state = Rc::clone(state); let runtime = runtime.clone(); #[cfg(mobile)] let documents = documents.clone(); #[cfg(mobile)] let paths = paths.clone(); #[cfg(mobile)] let staging = Arc::clone(staging); let running = Rc::new(Cell::new(false)); app.on_export_device_mail(move |account_id| { let Some(app) = app_weak.upgrade() else { return; }; if running.get() { return; } let (core, accounts) = { let state = state.borrow(); let mut accounts = state .account_configs .iter() .filter(|config| { config.mail_protocol == MailProtocol::Pop3 && (account_id < 0 || config.id == i64::from(account_id)) }) .map(|config| (config.id, config.email.clone())) .collect::>(); // Local mail's folder is named like its section of the sidebar. if let Some(local) = state .local_mail .filter(|local| account_id < 0 || local.account_id == i64::from(account_id)) { accounts.push(( local.account_id, translated(&app, &UiMessage::plain("On this device")).to_string(), )); } (state.core.clone(), accounts) }; let Some(core) = core else { app.set_mail_export_status(UiMessage::plain( "Local data storage is unavailable. Retry startup.", )); return; }; let name = match accounts.as_slice() { [] => return, [(_, email)] => format!( "flectar-mail-{}-{}", export_folder_name(email), Local::now().format("%Y-%m-%d-%H%M%S") ), _ => format!("flectar-mail-{}", Local::now().format("%Y-%m-%d-%H%M%S")), }; // Everything after the picker; the desktop picker hands it the folder. let start = { let app_weak = app.as_weak(); let running = Rc::clone(&running); let runtime = runtime.clone(); #[cfg(mobile)] let documents = documents.clone(); #[cfg(mobile)] let paths = paths.clone(); #[cfg(mobile)] let staging = Arc::clone(&staging); move |#[cfg(desktop)] destination: PathBuf| { let Some(app) = app_weak.upgrade() else { running.set(false); return; }; running.set(true); app.set_mail_export_running(true); app.set_mail_export_status(UiMessage::plain("Exporting mail…")); #[cfg(desktop)] let task = runtime.spawn(export_device_mail(core, destination.clone(), accounts)); #[cfg(mobile)] let task = runtime.spawn(stage_mail_export( core, paths.clone(), Arc::clone(&staging), name, accounts, )); let app_weak = app.as_weak(); let finished = Rc::clone(&running); #[cfg(mobile)] let documents = documents.clone(); #[cfg(mobile)] let runtime = runtime.clone(); let finish = move |message: UiMessage| { finished.set(false); if let Some(app) = app_weak.upgrade() { app.set_mail_export_running(false); app.set_mail_export_status(message); } }; let spawned = slint::spawn_local(async move { let result = task.await.unwrap_or_else(|error| Err(error.to_string())); #[cfg(desktop)] finish(match result { Ok(exported) => { mail_export_message(&exported, &destination.display().to_string()) } Err(error) => UiMessage::detail("Could not export mail: {}", error), }); #[cfg(mobile)] match result { Ok((staged, exported)) => { let request = documents.export(staged.archive.clone(), staged.name.clone()); let cleanup = runtime.clone(); documents::run_request(&runtime, request, move |saved| { let name = staged.name.clone(); cleanup.spawn(staged.remove()); finish(match saved { Ok(true) => mail_export_message(&exported, &name), Ok(false) => UiMessage::EMPTY, Err(error) => { UiMessage::detail("Could not export mail: {}", error) } }); }); } Err(error) => finish(UiMessage::detail("Could not export mail: {}", error)), } }); if let Err(error) = spawned { running.set(false); app.set_mail_export_running(false); app.set_mail_export_status(UiMessage::detail( "Could not export mail: {}", error, )); } } }; #[cfg(desktop)] { // The portal picker needs the runtime: rfd's blocking dialog // panics on the UI thread, which has no Tokio context. let title = translated(&app, &UiMessage::plain("Choose where to export the mail")).to_string(); let request: documents::DocumentFuture> = Box::pin(async move { Ok(rfd::AsyncFileDialog::new() .set_title(title) .pick_folder() .await .map(|folder| folder.path().join(name))) }); // Blocks a second picker until this one closes. running.set(true); let picking = Rc::clone(&running); let app_weak = app.as_weak(); documents::run_request(&runtime, request, move |picked| { picking.set(false); match picked { Ok(Some(destination)) => start(destination), Ok(None) => {} Err(error) => { if let Some(app) = app_weak.upgrade() { app.set_mail_export_status(UiMessage::detail( "Could not export mail: {}", error, )); } } } }); } #[cfg(mobile)] start(); }); } /// Clears the in-progress flag when the snapshot task ends, even by panic. struct SnapshotRunning(Arc); impl Drop for SnapshotRunning { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } #[allow(clippy::too_many_arguments)] pub(super) fn register_data_management_callbacks( app: &AppWindow, paths: &Paths, documents: &std::sync::Arc, state: &Rc>, runtime: &tokio::runtime::Handle, // Only the desktop snapshot export reports through the task channel. #[cfg_attr(mobile, allow(unused_variables))] ui_task_tx: &UiSender, contact_state: &Rc>, contacts_loaded: &Rc>, contacts_loading: &Rc>, contact_load_generation: &Rc>, calendar_state: &Rc>, ) { // Directory walking stays completely dormant until the Storage page asks // for it. A dedicated, cancellable worker avoids consuming either the UI // thread or Tokio's two workers used for interactive mail operations. let storage_generation = Arc::new(AtomicU64::new(0)); let cancel_generation = Arc::clone(&storage_generation); let cancel_app = app.as_weak(); app.on_cancel_storage_usage(move || { cancel_generation.fetch_add(1, Ordering::AcqRel); if let Some(app) = cancel_app.upgrade() { app.set_storage_loading(false); } }); let load_generation = Arc::clone(&storage_generation); let storage_paths = paths.clone(); let storage_app = app.as_weak(); let storage_state = Rc::clone(state); let storage_runtime = runtime.clone(); app.on_load_storage_usage(move || { let Some(app) = storage_app.upgrade() else { return; }; if app.get_storage_loading() || !app.get_settings_open() || app.get_settings_tab() != "Data" { return; } // Deleted and moved mail changes what local mail holds. refresh_local_mail(&app, &storage_state, &storage_runtime); let ticket = load_generation.fetch_add(1, Ordering::AcqRel) + 1; app.set_storage_loading(true); app.set_storage_error("".into()); let worker_generation = Arc::clone(&load_generation); let result_generation = Arc::clone(&load_generation); let paths = storage_paths.clone(); let weak = app.as_weak(); let spawn = std::thread::Builder::new() .name("flectar-storage-scan".into()) .spawn(move || { let Some(scan) = scan_storage(&paths, &worker_generation, ticket) else { return; }; let total = scan.bytes.iter().copied().sum::(); let rows = storage_rows(&scan); let incomplete = scan.unreadable_entries > 0; let _ = weak.upgrade_in_event_loop(move |app| { if result_generation.load(Ordering::Acquire) != ticket { return; } app.set_storage_total(format_storage_size(total).into()); app.set_storage_usage(ModelRc::new(VecModel::from(rows))); app.set_storage_error(if incomplete { "incomplete".into() } else { "".into() }); app.set_storage_loaded(true); app.set_storage_loading(false); }); }); if spawn.is_err() { app.set_storage_loading(false); app.set_storage_error("failed".into()); } }); let app_weak = app.as_weak(); let state_for_backup = Rc::clone(state); let runtime_for_backup = runtime.clone(); let documents_for_backup = documents.clone(); let paths_for_backup = paths.clone(); let exporting_backup = Rc::new(Cell::new(false)); app.on_export_account_backup(move || { let Some(app) = app_weak.upgrade() else { return; }; // The dialog does not block the window; a second click while it is // open would start a second export. if exporting_backup.get() { return; } let (core, configs) = { let state = state_for_backup.borrow(); (state.core.clone(), state.account_configs.clone()) }; if configs.is_empty() { app.set_sync_status(UiMessage::plain( "Connect an account before creating a backup.", )); return; } let preferences = core .as_ref() .and_then(|core| runtime_for_backup.block_on(core.load_settings()).ok()) .map(|settings| { let mail_profiles = settings .mail_profiles .iter() .map(|profile| PortableMailProfile { name: profile.name.clone(), color: profile.color.clone(), account_emails: configs .iter() .filter(|account| profile.account_ids.contains(&account.id)) .map(|account| account.email.clone()) .collect(), }) .collect::>(); let account_colors = configs .iter() .filter_map(|account| { settings .account_colors .get(&account.id.to_string()) .map(|color| (account.email.clone(), color.clone())) }) .collect::>(); serde_json::json!({ "theme": settings.theme, "messageAppearance": settings.message_appearance, "showAvatars": settings.show_avatars, "showTabs": settings.show_tabs, "conversationView": settings.conversation_view, "workspaceLayout": settings.workspace_layout, "workspaceListPaneWidth": settings.workspace_list_pane_width, "monochromeSidebarIcons": settings.monochrome_sidebar_icons, "appIcon": settings.app_icon, "language": settings.language, "timeFormat": settings.time_format, "calendarWeekStart": settings.calendar_week_start, "loadRemoteImages": settings.load_remote_images, "markReadOnOpen": settings.mark_read_on_open, "collectOutgoingContacts": settings.collect_outgoing_contacts, "collectIncomingContacts": settings.collect_incoming_contacts, "contactSuggestAllAccounts": settings.contact_suggest_all_accounts, "suggestLearnedContacts": settings.suggest_learned_contacts, "notificationsEnabled": settings.notifications_enabled, "notificationScope": settings.notification_scope, "soundEnabled": settings.sound_enabled, "syncIntervalMinutes": settings.sync_interval_minutes, "closeToTray": settings.close_to_tray, "windowFrame": settings.window_frame, "showAccountMarkers": settings.show_account_badges, "accountColors": account_colors, "mailProfiles": mail_profiles }) }) .unwrap_or_else(|| serde_json::json!({})); let accounts = configs .iter() .map(PortableAccountConfig::from) .collect::>(); let backup = serde_json::json!({ "format": account_backup::FORMAT, "version": account_backup::VERSION, "createdAt": Utc::now().to_rfc3339(), "includesCredentials": false, "accounts": accounts, "preferences": preferences }); let bytes = match serde_json::to_vec_pretty(&backup) { Ok(bytes) => bytes, Err(error) => { app.set_sync_status(UiMessage::detail("Could not export backup: {}", error)); return; } }; let name = format!( "flectar-mail-backup-{}.json", Local::now().format("%Y-%m-%d") ); let dialog = backup_dialog(&app, UiMessage::plain("Export Flectar Mail account backup")); let request = export_document_bytes( documents_for_backup.clone(), paths_for_backup.temp_dir(), bytes, name, dialog, ); let app_weak = app.as_weak(); let exporting = Rc::clone(&exporting_backup); exporting.set(true); documents::run_request(&runtime_for_backup, request, move |result| { exporting.set(false); let Some(app) = app_weak.upgrade() else { return; }; match result { Ok(true) => app.set_sync_status(UiMessage::plain( "Backup exported. Passwords and OAuth tokens were not included.", )), Ok(false) => {} Err(error) => { app.set_sync_status(UiMessage::detail("Could not export backup: {}", error)) } } }); }); // Snapshots and mail exports take turns in one staging root on mobile. let export_staging = Arc::new(tokio::sync::Mutex::new(())); // A killed export leaves a full copy in staging; reclaim it now instead of // at the next export. #[cfg(mobile)] if let Ok(root) = export_staging_root(paths) { let staging = Arc::clone(&export_staging); runtime.spawn(async move { let _staging = staging.lock().await; let _ = tokio::fs::remove_dir_all(root).await; }); } register_mail_export(app, state, runtime, documents, paths, &export_staging); let app_weak = app.as_weak(); let state_for_database_snapshot = Rc::clone(state); let runtime_for_database_snapshot = runtime.clone(); #[cfg(desktop)] let updates_for_database_snapshot = ui_task_tx.clone(); #[cfg(mobile)] let documents_for_database_snapshot = documents.clone(); #[cfg(mobile)] let paths_for_database_snapshot = paths.clone(); #[cfg(mobile)] let snapshot_staging = Arc::clone(&export_staging); let database_snapshot_running = Arc::new(AtomicBool::new(false)); #[cfg(desktop)] let picking_database_snapshot = Rc::new(Cell::new(false)); app.on_export_database_snapshot(move || { let Some(app) = app_weak.upgrade() else { return; }; let Some(core) = state_for_database_snapshot.borrow().core.clone() else { app.set_sync_status(UiMessage::plain( "Local data storage is unavailable. Retry startup.", )); return; }; if database_snapshot_running.load(Ordering::Acquire) { app.set_sync_status(UiMessage::plain("Creating verified database snapshot…")); return; } // The portal picker needs the runtime: rfd's blocking dialog panics on // the UI thread, which has no Tokio context. #[cfg(desktop)] { if picking_database_snapshot.replace(true) { return; } let title = translated( &app, &UiMessage::plain("Choose where to export the database snapshot"), ) .to_string(); let request: documents::DocumentFuture> = Box::pin(async move { Ok(pick_database_snapshot_path(title).await) }); let picking = Rc::clone(&picking_database_snapshot); let snapshot_running = Arc::clone(&database_snapshot_running); let runtime = runtime_for_database_snapshot.clone(); let updates = updates_for_database_snapshot.clone(); let app_weak = app.as_weak(); documents::run_request(&runtime_for_database_snapshot, request, move |picked| { picking.set(false); let Some(app) = app_weak.upgrade() else { return; }; let path = match picked { Ok(Some(path)) => path, Ok(None) => return, Err(error) => { app.set_sync_status(UiMessage::detail( "Could not export database snapshot: {}", error, )); return; } }; snapshot_running.store(true, Ordering::Release); app.set_sync_status(UiMessage::plain("Creating verified database snapshot…")); let running = SnapshotRunning(snapshot_running); runtime.spawn(async move { let display_path = path.display().to_string(); let message = match core.create_database_snapshot(path).await { Ok(_) => { UiMessage::detail("Database snapshot exported to {}.", display_path) } Err(error) => { UiMessage::detail("Could not export database snapshot: {}", error) } }; drop(running); let _ = updates .send(UiTaskUpdate { message, accounts: None, calendar_connections: None, carddav_connections: None, calendar_error: None, account_removal: None, clear_account_form: false, finishes_account_setup: false, finishes_oauth: false, close_to_tray: None, }) .await; }); }); } // Staging runs on the runtime; only the export picker goes through // the shared document request path. #[cfg(mobile)] { database_snapshot_running.store(true, Ordering::Release); app.set_sync_status(UiMessage::plain("Creating verified database snapshot…")); let running = SnapshotRunning(Arc::clone(&database_snapshot_running)); let staged = runtime_for_database_snapshot.spawn(stage_database_snapshot( core, paths_for_database_snapshot.clone(), Arc::clone(&snapshot_staging), )); let abort_staging = staged.abort_handle(); let app_weak = app.as_weak(); let documents = documents_for_database_snapshot.clone(); let runtime = runtime_for_database_snapshot.clone(); let spawned = slint::spawn_local(async move { let snapshot = match staged.await.unwrap_or_else(|error| Err(error.to_string())) { Ok(snapshot) => snapshot, Err(error) => { drop(running); if let Some(app) = app_weak.upgrade() { app.set_sync_status(UiMessage::detail( "Could not export database snapshot: {}", error, )); } return; } }; let request = documents.export(snapshot.archive.clone(), snapshot.name.clone()); let cleanup = runtime.clone(); documents::run_request(&runtime, request, move |exported| { let name = snapshot.name.clone(); cleanup.spawn(snapshot.remove()); drop(running); let Some(app) = app_weak.upgrade() else { return; }; app.set_sync_status(match exported { Ok(true) => UiMessage::detail("Database snapshot exported to {}.", name), // Cancelled at the picker: return the status line to idle. Ok(false) => UiMessage::plain("Local data ready."), Err(error) => { UiMessage::detail("Could not export database snapshot: {}", error) } }); }); }); if let Err(error) = spawned { abort_staging.abort(); app.set_sync_status(UiMessage::detail( "Could not export database snapshot: {}", error, )); } } }); let (data_reset_raw_tx, data_reset_rx) = bounded_ui_channel::>(); let data_reset_tx = UiSender::new( data_reset_raw_tx, UiWake::new(app.as_weak(), |app| app.invoke_drain_data_reset_updates()), ); let data_reset_rx = Rc::new(RefCell::new(data_reset_rx)); let data_reset_app = app.as_weak(); let data_reset_state = Rc::clone(state); let data_reset_contacts = Rc::clone(contact_state); let data_reset_contacts_loaded = Rc::clone(contacts_loaded); let data_reset_contacts_loading = Rc::clone(contacts_loading); let data_reset_contact_generation = Rc::clone(contact_load_generation); let data_reset_calendar = Rc::clone(calendar_state); let data_reset_runtime = runtime.clone(); app.on_drain_data_reset_updates(move || { while let Ok(update) = data_reset_rx.borrow_mut().try_recv() { let Some(app) = data_reset_app.upgrade() else { return; }; match update { Ok(()) => { { let mut state = data_reset_state.borrow_mut(); state.connected_accounts.clear(); state.account_configs.clear(); state.account_presentation = AccountPresentationSettings::default(); let _ = state.sync_profile_accounts(); state.calendar_connections.clear(); state.messages.clear(); state.mailboxes.clear(); state.unified_mailboxes.clear(); state.inbox_count = 0; state.next_cursor = None; state.selected_id = None; state.rendered_id = None; state.using_core = true; state.mark_read_on_open = true; } refresh_connected_accounts(&app, &data_reset_state); let _ = render_current(&app, &data_reset_state, &data_reset_runtime); { let mut contacts = data_reset_contacts.borrow_mut(); contacts.contacts.clear(); contacts.selected_id = None; contacts.query.clear(); contacts.scope = "All contacts".to_owned(); contacts.page = 1; contacts.next_cursor = None; contacts.total_count = 0; contacts.favorite_count = 0; contacts.account_counts.clear(); contacts.editing_new = false; contacts.using_core = true; } data_reset_contact_generation .set(data_reset_contact_generation.get().wrapping_add(1)); data_reset_contacts_loaded.set(true); data_reset_contacts_loading.set(false); app.set_contact_loading_more(false); app.set_contact_list_revision(app.get_contact_list_revision().wrapping_add(1)); apply_contact_directory(&app, &data_reset_contacts); let today = Local::now().date_naive(); { let mut calendar = data_reset_calendar.borrow_mut(); calendar.events.clear(); calendar.selected_date = today; calendar.visible_month = first_of_month(today); } apply_calendar(&app, &data_reset_calendar.borrow(), today); app.set_theme_mode("system".into()); app.global::().set_preference("system".into()); app.set_monochrome_sidebar_icons(false); crate::app_icon::apply(&app, Default::default()); app.set_show_avatars(true); app.set_show_tabs(true); apply_mail_grouping( &app, &data_reset_state, &data_reset_runtime, MailGrouping::Conversations, ); app.set_show_account_markers(false); app.set_workspace_layout("default".into()); app.set_workspace_list_pane_width(MIN_WORKSPACE_LIST_PANE_WIDTH as f32); app.set_notifications_enabled(true); app.set_notification_sound_enabled(true); app.set_notification_scope("important".into()); app.set_sync_interval_minutes(5); app.set_mark_read_on_open(true); app.set_remote_images_enabled(false); app.set_close_to_tray(false); crate::window_chrome::apply_preference( &app, crate::window_chrome::WindowFrame::default(), ); app.global::().invoke_reset(); app.invoke_cancel_storage_usage(); app.set_storage_loaded(false); app.set_storage_usage(ModelRc::default()); app.invoke_load_storage_usage(); app.set_sync_status(UiMessage::plain( "All local Flectar Mail data was deleted.", )); } Err(error) => app.set_sync_status(UiMessage::detail( "Could not delete all local data: {}", error, )), } } }); let app_weak = app.as_weak(); let state_for_data_reset = Rc::clone(state); let runtime_for_data_reset = runtime.clone(); app.on_delete_all_data(move || { let Some(app) = app_weak.upgrade() else { return; }; let Some(core) = state_for_data_reset.borrow().core.clone() else { app.set_sync_status(UiMessage::plain( "Local data storage is unavailable. Retry startup.", )); return; }; app.set_sync_status(UiMessage::plain("Deleting local accounts and cached data…")); let updates = data_reset_tx.clone(); runtime_for_data_reset.spawn(async move { let result = core.delete_all_local_data().await; let _ = updates.send(result).await; }); }); } #[cfg(test)] mod tests { use super::*; #[test] fn storage_scan_lifetime_follows_settings_visibility() { use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; struct Headless(Rc); impl slint::platform::Platform for Headless { fn create_window_adapter( &self, ) -> Result, slint::PlatformError> { Ok(self.0.clone()) } } let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); slint::platform::set_platform(Box::new(Headless(window))).unwrap(); let app = AppWindow::new().unwrap(); let calls = Rc::new(RefCell::new(Vec::new())); let loads = calls.clone(); app.on_load_storage_usage(move || loads.borrow_mut().push("load")); let cancellations = calls.clone(); app.on_cancel_storage_usage(move || cancellations.borrow_mut().push("cancel")); slint::platform::update_timers_and_animations(); calls.borrow_mut().clear(); app.set_settings_open(true); slint::platform::update_timers_and_animations(); assert!(calls.borrow().is_empty()); app.set_settings_tab("Data".into()); slint::platform::update_timers_and_animations(); app.set_settings_open(false); slint::platform::update_timers_and_animations(); app.set_settings_open(true); slint::platform::update_timers_and_animations(); app.set_settings_tab("About".into()); slint::platform::update_timers_and_animations(); assert_eq!(*calls.borrow(), ["load", "cancel", "load", "cancel"]); } #[test] fn storage_scan_counts_app_data_by_category_and_honors_cancellation() { let root = tempfile::tempdir().unwrap(); let paths = Paths::new(root.path().join("data"), root.path().join("cache")); for directory in [ paths.mail_dir(1), paths.attachments_dir(1), paths.draft_attachments_dir(), paths.files_cache_dir(1), ] { std::fs::create_dir_all(directory).unwrap(); } std::fs::write(paths.mail_dir(1).join("message.eml"), [0; 11]).unwrap(); std::fs::write(paths.attachments_dir(1).join("received.bin"), [0; 13]).unwrap(); std::fs::write(paths.draft_attachments_dir().join("draft.bin"), [0; 17]).unwrap(); std::fs::write(paths.files_cache_dir(1).join("offline.bin"), [0; 19]).unwrap(); std::fs::write(paths.db_file(), [0; 23]).unwrap(); std::fs::write(paths.data_dir.join("settings.json"), [0; 29]).unwrap(); std::fs::write(paths.warm_start_file(), [0; 31]).unwrap(); let generation = AtomicU64::new(7); let scan = scan_storage(&paths, &generation, 7).unwrap(); assert_eq!(scan.bytes, [11, 30, 19, 23, 60]); assert_eq!(scan.unreadable_entries, 0); assert!(scan_storage(&paths, &generation, 6).is_none()); } #[test] fn storage_scan_counts_overlapping_roots_once() { for nesting in 0..3 { let root = tempfile::tempdir().unwrap(); let outer = root.path().join("outer"); let inner = outer.join("inner"); let paths = match nesting { 0 => Paths::new(outer, inner), 1 => Paths::new(inner, outer), _ => Paths::new(outer.clone(), outer), }; std::fs::create_dir_all(paths.mail_dir(1)).unwrap(); std::fs::create_dir_all(paths.attachments_dir(1)).unwrap(); std::fs::write(paths.mail_dir(1).join("mail.eml"), [0; 11]).unwrap(); std::fs::write(paths.attachments_dir(1).join("attachment"), [0; 13]).unwrap(); std::fs::write(paths.db_file(), [0; 17]).unwrap(); let scan = scan_storage(&paths, &AtomicU64::new(0), 0).unwrap(); assert_eq!(scan.bytes, [11, 13, 0, 17, 0]); } } #[cfg(unix)] #[test] fn storage_scan_skips_symlinks_and_cycles() { let root = tempfile::tempdir().unwrap(); let paths = Paths::for_tests(root.path()); std::fs::create_dir_all(paths.mail_dir(1)).unwrap(); let file = paths.mail_dir(1).join("mail.eml"); std::fs::write(&file, [0; 11]).unwrap(); std::os::unix::fs::symlink(&file, paths.data_dir.join("alias")).unwrap(); std::os::unix::fs::symlink(&paths.data_dir, paths.data_dir.join("cycle")).unwrap(); let scan = scan_storage(&paths, &AtomicU64::new(0), 0).unwrap(); assert_eq!(scan.bytes, [11, 0, 0, 0, 0]); assert_eq!(scan.unreadable_entries, 0); } #[test] fn storage_database_category_requires_exact_database_or_sidecar() { for name in [ "flectar-mail.db", "flectar-calendar.db-wal", "flectar-files.db-shm", "flectar-mail.db-journal", ] { assert_eq!( storage_category(StorageRoot::Data, name.as_ref()), STORAGE_DATABASES ); } for name in [ "flectar-mail.db.backup", "flectar-files.db-old", "other/flectar-mail.db", ] { assert_eq!( storage_category(StorageRoot::Data, name.as_ref()), STORAGE_OTHER ); } } #[test] fn storage_rows_are_finite_for_empty_and_uneven_usage() { let empty = storage_rows(&StorageScan::default()); assert!( empty .iter() .all(|row| row.fraction == 0.0 && row.offset == 0.0) ); let scan = StorageScan { bytes: [1, 0, 999, 0, 0], unreadable_entries: 0, }; let rows = storage_rows(&scan); assert!((rows[0].fraction - 0.001).abs() < 0.000001); assert!((rows[2].offset - 0.001).abs() < 0.000001); assert!((rows[4].offset - 1.0).abs() < 0.000001); } #[test] fn storage_size_uses_binary_units() { assert_eq!(format_storage_size(0), "0 B"); assert_eq!(format_storage_size(1536), "1.5 KB"); assert_eq!(format_storage_size(3 * 1024 * 1024), "3.0 MB"); assert_eq!(format_storage_size(5 * 1024 * 1024 * 1024), "5.00 GB"); } #[test] fn portable_profiles_distinguish_legacy_backups_from_explicit_unassignment() { let emails = HashSet::from(["alice@example.org".to_owned()]); let legacy = serde_json::json!({ "preferences": {} }); assert!(portable_mail_profiles(&legacy, &emails).unwrap().is_none()); let explicit_empty = serde_json::json!({ "preferences": { "mailProfiles": [] } }); assert_eq!( portable_mail_profiles(&explicit_empty, &emails) .unwrap() .unwrap() .len(), 0 ); } #[test] fn portable_profiles_reject_cross_backup_and_duplicate_membership() { let emails = HashSet::from(["alice@example.org".to_owned(), "bob@example.org".to_owned()]); let duplicate = serde_json::json!({ "preferences": { "mailProfiles": [ { "name": "Work", "color": "#3B82F6", "accountEmails": ["alice@example.org"] }, { "name": "Personal", "color": "#EA580C", "accountEmails": ["ALICE@example.org"] } ] } }); assert!(portable_mail_profiles(&duplicate, &emails).is_err()); let outside = serde_json::json!({ "preferences": { "mailProfiles": [ { "name": "Work", "color": "#3B82F6", "accountEmails": ["mallory@example.org"] } ] } }); assert!(portable_mail_profiles(&outside, &emails).is_err()); } #[test] fn portable_account_colors_are_optional_validated_and_normalized() { let emails = HashSet::from(["work@example.com".to_owned()]); let legacy = serde_json::json!({ "preferences": {} }); assert!(portable_account_colors(&legacy, &emails).unwrap().is_none()); let valid = serde_json::json!({ "preferences": { "accountColors": { " Work@Example.com ": "#f97316" } } }); let colors = portable_account_colors(&valid, &emails).unwrap().unwrap(); assert_eq!( colors.get("work@example.com").map(String::as_str), Some("#f97316") ); let invalid = serde_json::json!({ "preferences": { "accountColors": { "work@example.com": "orange" } } }); assert!(portable_account_colors(&invalid, &emails).is_err()); let outside = serde_json::json!({ "preferences": { "accountColors": { "other@example.com": "#000000" } } }); assert!(portable_account_colors(&outside, &emails).is_err()); } #[test] fn snapshot_archive_keeps_the_tree_and_skips_symlinks() { let staging = tempfile::tempdir().unwrap(); let snapshot = staging.path().join("snapshot"); std::fs::create_dir_all(snapshot.join("file_transfers")).unwrap(); std::fs::write(snapshot.join("mail.sqlite3"), b"mail").unwrap(); std::fs::write(snapshot.join("file_transfers/part"), b"part").unwrap(); let outside = staging.path().join("outside"); std::fs::write(&outside, b"private").unwrap(); #[cfg(unix)] std::os::unix::fs::symlink(&outside, snapshot.join("link")).unwrap(); let archive = staging.path().join("snapshot.zip"); archive_directory(&snapshot, &archive, u64::MAX).unwrap(); let mut zip = zip::ZipArchive::new(std::fs::File::open(&archive).unwrap()).unwrap(); let mut names = zip .file_names() .map(|name| name.unwrap().into_owned()) .collect::>(); names.sort(); assert_eq!( names, ["file_transfers/", "file_transfers/part", "mail.sqlite3"] ); let mut contents = String::new(); std::io::Read::read_to_string(&mut zip.by_name("mail.sqlite3").unwrap(), &mut contents) .unwrap(); assert_eq!(contents, "mail"); } #[test] fn snapshot_archive_refuses_stores_over_the_transfer_limit() { let staging = tempfile::tempdir().unwrap(); let snapshot = staging.path().join("snapshot"); std::fs::create_dir_all(&snapshot).unwrap(); std::fs::write(snapshot.join("mail.sqlite3"), [0; 64]).unwrap(); let archive = staging.path().join("snapshot.zip"); let error = archive_directory(&snapshot, &archive, 63).unwrap_err(); assert!(error.contains("64 B"), "{error}"); assert!(!archive.exists()); // Headers alone push an archive of a store at the limit past it. assert!(archive_directory(&snapshot, &archive, 64).is_err()); archive_directory(&snapshot, &archive, 4096).unwrap(); } }