//! Import flow for accounts from other mail applications and Flectar backups, //! and for the mail other applications keep or that was saved to files. //! //! Core reads and plans the source off the UI thread; this controller keeps //! the reviewed plan, applies the user's selection, and presents results. //! Accounts from other applications, and mail, are written only after the //! user confirms the review steps; a Flectar backup is a restore and applies //! directly, as it always has. A stale result from an abandoned step or an //! earlier opening never replaces the current one. use super::*; use crate::data_controller::{format_storage_size, restore_account_backup}; use flectar_mail_core::mail_import::{ self, ChosenFile, Destination, FolderKind, MailImportProgress, MailImportRequest, MailSource, SourceFolder, }; use flectar_mail_core::migration::{ self, AccountImportReport, AccountPlan, MailStore, OtherEntryKind, PlanStatus, Severity, SignIn, Source, SourceError, }; use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::watch; /// Reads one selected source on a blocking worker. type SourceReader = Box Result + Send>; /// The most files chosen at once that a phone copies. const MAX_CHOSEN_FILES: usize = 1000; #[derive(Default)] struct ImportSession { plans: Vec, selected: Vec, source: Option, /// Mail the source keeps, by store, and the folders chosen from it. mail: Vec, /// Where mail can go: this device, then accounts. destinations: Vec>, /// What each mail row shows: a store, or one of its folders. rows: Vec<(usize, Option)>, /// Addresses the source's accounts send from. Mail from them is filed /// as sent. own_addresses: Vec, /// The chosen files. A phone's copy of one must outlive the mail import. documents: Vec, /// Stops the mail import that is running. stop: Option>, /// The first folder imported mail went into. imported_folder: Option, #[cfg(desktop)] profiles: Vec, /// Bumped whenever the dialog moves on, so late results are dropped. generation: u64, /// Bumped when the dialog opens or closes; profile discovery and pickers /// belong to one opening and stay valid across steps within it. opening: u64, /// A file or folder picker is open; a second one must not start. picking: bool, } impl ImportSession { fn advance(&mut self) -> u64 { self.generation += 1; self.generation } /// Forget the chosen source, as when going back to choose another. fn clear_source(&mut self) { self.plans.clear(); self.selected.clear(); self.source = None; self.mail.clear(); self.destinations.clear(); self.rows.clear(); self.own_addresses.clear(); self.documents.clear(); } fn selected_drafts(&self) -> Vec { self.plans .iter() .zip(&self.selected) .filter(|(_, selected)| **selected) .filter_map(|(plan, _)| plan.draft.clone()) .collect() } } /// An account mail can go into, by address and protocol: one here, or one /// chosen for import. #[derive(Debug, Clone, PartialEq)] struct MailAccount { email: String, protocol: MailProtocol, } impl MailAccount { /// Its mail is on its server, so mail imported into it is uploaded /// there. A POP3 account's exists only on this device. fn uploads(&self) -> bool { self.protocol != MailProtocol::Pop3 } } /// One store of mail, the folders chosen from it, and where it goes. struct MailChoice { store: MailStore, selected: Vec, /// An index into the session's destinations. destination: usize, /// The folder its mail goes in on an account's server; without one, the /// folder for messages chosen one by one. upload_folder: Option, } impl MailChoice { /// A store of an app's profile, whose mail is named after it on a /// server. fn from_profile(store: MailStore) -> Self { let upload_folder = Some(store.label.clone()); Self { upload_folder, ..Self::new(store) } } fn new(store: MailStore) -> Self { // Mail that was thrown away or was junk is left out unless chosen. let tops = store .mail .folders .iter() .filter(|folder| folder.path.len() == 1) .map(|folder| (folder.path[0].as_str(), folder.kind)) .collect::>(); let selected = store .mail .folders .iter() .map(|folder| { !folder.path.first().is_some_and(|top| { matches!( tops.get(top.as_str()), Some(FolderKind::Trash | FolderKind::Junk) ) }) }) .collect(); Self { store, selected, destination: 0, upload_folder: None, } } fn folders(&self) -> &[SourceFolder] { &self.store.mail.folders } /// The folders below folder `index`, which follow it in the list. fn subfolders(&self, index: usize) -> std::ops::Range { let folders = self.folders(); let path = &folders[index].path; let end = if path.is_empty() { index + 1 } else { folders[index + 1..] .iter() .position(|folder| { folder.path.len() <= path.len() || !folder.path.starts_with(path) }) .map_or(folders.len(), |offset| index + 1 + offset) }; index + 1..end } /// The chosen folders that hold messages themselves. fn chosen(&self) -> impl Iterator { (0..self.folders().len()) .filter(|index| self.selected[*index] && self.folders()[*index].has_messages()) } /// Whether all, or some, of the folders in `range` that hold messages are /// chosen. fn state(&self, range: std::ops::Range) -> (bool, bool) { let mut all = true; let mut any = false; let folders = &self.folders()[range.clone()]; for (folder, selected) in folders.iter().zip(&self.selected[range]) { if folder.has_messages() { all &= *selected; any |= *selected; } } (all && any, any && !all) } } /// Mail of one store to import. struct MailJob { label: String, source: MailSource, folders: Vec, bytes: u64, destination: Option, /// The folder the mail goes in on this device, if it goes there. parent: Option, /// The folder it goes in on an account's server. upload_folder: Option, } /// How far the whole import is, across stores. #[derive(Debug, Clone, Default)] struct Progress { /// Mail is being imported, after any accounts. mail: bool, done_bytes: u64, total_bytes: u64, imported: u64, /// The folder being imported. folder: String, } #[derive(Debug, Default)] struct MailOutcome { imported: u64, already_present: u64, too_large: u64, /// Messages a server refused, and why it refused the first. refused: u64, refusal: Option, stopped: bool, /// Folders not read to the end, by name, with the reason. unreadable: Vec<(String, String)>, folders: Vec, error: Option, } struct ImportOutcome { accounts: Option, mail: Option, } /// Whether a source may be chosen now: the dialog is on its first step and /// no picker is already open. fn can_choose(app: &AppWindow, session: &ImportSession) -> bool { let ui = app.global::(); ui.get_open() && ui.get_stage() == "choose" && !session.picking } /// Marks a picker open and returns the opening its result belongs to. fn start_picking(app: &AppWindow, session: &RefCell) -> Option { let mut session = session.borrow_mut(); if !can_choose(app, &session) { return None; } session.picking = true; // A phone copies what is chosen before it can be read, which takes a // while for a large file; the dialog behind its picker shows that. if cfg!(mobile) { app.global::().set_stage("reading".into()); } Some(session.opening) } /// Ends a picker. Returns whether its result still applies: the user may /// have closed the dialog, or reopened it, while the picker was up. fn finish_picking(app: &AppWindow, session: &RefCell, opening: u64) -> bool { let mut session = session.borrow_mut(); if session.opening != opening { return false; } session.picking = false; let ui = app.global::(); if cfg!(mobile) && ui.get_stage() == "reading" { ui.set_stage("choose".into()); } can_choose(app, &session) } /// What reading a source produced, ready for the UI thread. enum Inspected { Plans { source: migration::SourceKind, plans: Vec, other: Vec, mail: Vec, own_addresses: Vec, }, Backup(Vec), Mail(MailSource), } fn source_error(error: SourceError) -> (&'static str, String) { match error { SourceError::Unreadable(detail) => ("unreadable", detail), SourceError::TooLarge => ("too-large", String::new()), SourceError::UnknownFormat => ("unknown-format", String::new()), SourceError::Malformed { reason, .. } => ("malformed", reason), SourceError::UnsupportedVersion(version) => ("unsupported-version", version), SourceError::NoAccounts => ("no-accounts", String::new()), SourceError::MultipleProfiles => ("multiple-profiles", String::new()), SourceError::NotAProfile => ("not-a-profile", String::new()), SourceError::NetworkProfile => ("network-profile", String::new()), SourceError::NoMail => ("no-mail", String::new()), } } fn show_error(app: &AppWindow, code: &str, detail: String) { let ui = app.global::(); ui.set_error_code(code.into()); ui.set_error_detail(detail.into()); ui.set_stage("error".into()); } /// Core is missing only when startup could not open local storage. fn show_storage_unavailable(app: &AppWindow) { let message = translated( app, &UiMessage::plain("Local data storage is unavailable. Retry startup."), ); show_error(app, "failed", message.to_string()); } fn account_row(plan: &AccountPlan, selected: bool) -> ImportAccountRow { let findings = plan .findings .iter() .map(|finding| ImportFindingRow { code: finding.code.as_str().into(), count: finding.detail.parse().unwrap_or_default(), detail: finding.detail.as_str().into(), blocking: finding.severity() == Severity::Blocking, }) .collect::>(); ImportAccountRow { email: plan.email.as_str().into(), label: plan.label.as_deref().unwrap_or_default().into(), server: plan .draft .as_ref() .map(|draft| draft.config.incoming_host.as_str()) .unwrap_or_default() .into(), status: match plan.status { PlanStatus::Ready => "ready", PlanStatus::AlreadyAdded => "already-added", PlanStatus::Unsupported => "unsupported", } .into(), sign_in: match plan.sign_in { SignIn::Password => "password", SignIn::Google => "google", SignIn::Microsoft => "microsoft", } .into(), selected, findings: ModelRc::new(VecModel::from(findings)), } } fn show_review(app: &AppWindow, session: &ImportSession) { let ui = app.global::(); let rows = session .plans .iter() .zip(&session.selected) .map(|(plan, selected)| account_row(plan, *selected)) .collect::>(); ui.set_source( session .source .map(migration::SourceKind::as_str) .unwrap_or_default() .into(), ); ui.set_accounts(ModelRc::new(VecModel::from(rows))); ui.set_selected_count( session .selected .iter() .filter(|selected| **selected) .count() as i32, ); ui.set_has_mail(!session.mail.is_empty()); ui.set_stage("review".into()); } /// The accounts mail can go into: those here, and the POP3 accounts chosen /// for import. A POP3 account's mail, like local mail, exists only on this /// device; mail for another account is uploaded to its server, so it needs /// one already signed in. fn mail_destinations(session: &ImportSession, accounts: &[Account]) -> Vec> { let mut destinations: Vec> = vec![None]; let here = accounts.iter().map(|account| MailAccount { email: account.email.clone(), protocol: account.mail_protocol, }); let chosen = session .plans .iter() .zip(&session.selected) .filter(|(_, selected)| **selected) .filter_map(|(plan, _)| plan.draft.as_ref()) .filter(|draft| draft.config.mail_protocol == MailProtocol::Pop3) .map(|draft| MailAccount { email: draft.config.email.clone(), protocol: MailProtocol::Pop3, }); for account in here.chain(chosen) { if !destinations.iter().flatten().any(|known| { known.protocol == account.protocol && known.email.eq_ignore_ascii_case(&account.email) }) { destinations.push(Some(account)); } } destinations } /// Where a store's mail goes unless the user says otherwise: a POP3 /// account's mail into that account, everything else onto this device. fn default_destination( session: &ImportSession, destinations: &[Option], store: &MailStore, ) -> usize { let Some(email) = store.account.as_ref().and_then(|account| { session .plans .iter() .find(|plan| plan.source_id == *account) .map(|plan| plan.email.as_str()) }) else { return 0; }; destinations .iter() .position(|destination| { destination .as_ref() .is_some_and(|known| !known.uploads() && known.email.eq_ignore_ascii_case(email)) }) .unwrap_or(0) } /// How the destination menu names a destination: by address, with the /// protocol where an address has more than one account. fn destination_name(destinations: &[Option], index: usize, device: &str) -> String { let Some(account) = &destinations[index] else { return device.to_owned(); }; let shared = destinations.iter().flatten().any(|other| { other.protocol != account.protocol && other.email.eq_ignore_ascii_case(&account.email) }); if !shared { return account.email.clone(); } let protocol = match account.protocol { MailProtocol::Imap => "IMAP", MailProtocol::Jmap => "JMAP", MailProtocol::Gmail => "Gmail", MailProtocol::Pop3 => "POP3", MailProtocol::Local => "", }; format!("{} ({protocol})", account.email) } /// Present the mail step for the accounts chosen before it. fn show_mail(app: &AppWindow, session: &mut ImportSession, accounts: &[Account]) { let destinations = mail_destinations(session, accounts); let previous = std::mem::take(&mut session.destinations); let chosen = session .mail .iter() .map(|choice| { // A destination chosen before is kept while it is still offered. previous .get(choice.destination) .filter(|_| choice.destination > 0) .and_then(|chosen| destinations.iter().position(|offered| offered == chosen)) .unwrap_or_else(|| default_destination(session, &destinations, &choice.store)) }) .collect::>(); for (choice, destination) in session.mail.iter_mut().zip(chosen) { choice.destination = destination; } session.destinations = destinations; let device = translated(app, &UiMessage::plain("On this device")).to_string(); let ui = app.global::(); ui.set_destinations(ModelRc::new(VecModel::from( (0..session.destinations.len()) .map(|index| destination_name(&session.destinations, index, &device).into()) .collect::>(), ))); show_mail_rows(app, session); ui.set_stage("mail".into()); } fn show_mail_rows(app: &AppWindow, session: &mut ImportSession) { let loose = translated(app, &UiMessage::plain("Imported messages")).to_string(); let mut rows = Vec::new(); let mut models = Vec::new(); let mut chosen = 0; let mut chosen_bytes = 0; for (store_index, choice) in session.mail.iter().enumerate() { let (selected, partial) = choice.state(0..choice.folders().len()); let uploads = session .destinations .get(choice.destination) .and_then(Option::as_ref) .is_some_and(MailAccount::uploads); rows.push((store_index, None)); models.push(ImportMailRow { is_store: true, name: choice.store.label.as_str().into(), depth: 0, size: format_storage_size(choice.store.mail.size()).into(), selected, partial, destination: choice.destination as i32, folder: if choice.store.account.is_some() { choice.store.label.as_str().into() } else { slint::SharedString::default() }, upload_folder: if uploads { choice .upload_folder .as_deref() .unwrap_or(loose.as_str()) .into() } else { slint::SharedString::default() }, }); for (index, folder) in choice.folders().iter().enumerate() { let (selected, partial) = if !folder.has_messages() { choice.state(choice.subfolders(index)) } else { (choice.selected[index], false) }; if selected && folder.has_messages() { chosen += 1; chosen_bytes += folder.size; } rows.push((store_index, Some(index))); models.push(ImportMailRow { is_store: false, name: folder .path .last() .map_or(loose.as_str(), String::as_str) .into(), depth: folder.path.len().saturating_sub(1) as i32, size: if !folder.has_messages() { slint::SharedString::default() } else { format_storage_size(folder.size).into() }, selected, partial, destination: 0, folder: slint::SharedString::default(), upload_folder: slint::SharedString::default(), }); } } session.rows = rows; let ui = app.global::(); ui.set_mail_rows(ModelRc::new(VecModel::from(models))); ui.set_mail_selected_count(chosen); ui.set_mail_selected_size(format_storage_size(chosen_bytes).into()); } /// The chosen mail, store by store. fn mail_jobs(session: &ImportSession) -> Vec { session .mail .iter() .filter_map(|choice| { let folders = choice.chosen().collect::>(); if folders.is_empty() { return None; } Some(MailJob { label: choice.store.label.clone(), bytes: folders .iter() .map(|index| choice.folders()[*index].size) .sum(), source: choice.store.mail.clone(), folders, destination: session .destinations .get(choice.destination) .cloned() .flatten(), // An account's mail stays apart from local mail's own. parent: choice .store .account .is_some() .then(|| choice.store.label.clone()), upload_folder: choice.upload_folder.clone(), }) }) .collect() } /// Import each store's chosen mail in turn, reporting progress across all /// of them. async fn import_mail( core: CoreMailSource, jobs: Vec, own_addresses: Vec, loose_folder_name: String, progress: watch::Sender, stop: Arc, ) -> MailOutcome { let mut outcome = MailOutcome::default(); let accounts = match core.load_accounts().await { Ok(accounts) => accounts, Err(error) => { outcome.error = Some(error); return outcome; } }; let total_bytes: u64 = jobs.iter().map(|job| job.bytes).sum(); let several = jobs.len() > 1; let mut done_bytes = 0; progress.send_replace(Progress { mail: true, done_bytes, total_bytes, ..Progress::default() }); for job in jobs { if stop.load(Ordering::Relaxed) { outcome.stopped = true; break; } let account = job.destination.as_ref().and_then(|destination| { accounts.iter().find(|account| { account.mail_protocol == destination.protocol && account.email.eq_ignore_ascii_case(&destination.email) }) }); let (destination, parent) = match account { Some(account) if account.mail_protocol == MailProtocol::Pop3 => { (Destination::Account(account.id), None) } Some(account) => (Destination::Account(account.id), job.upload_folder.clone()), // An account that couldn't be added leaves its mail on this // device, apart from the rest. None => ( Destination::Local, job.parent.clone().or_else(|| { job.destination .as_ref() .map(|destination| destination.email.clone()) }), ), }; // How progress names each of the job's folders. let names = job .source .folders .iter() .map(|folder| { let path = if folder.path.is_empty() { loose_folder_name.clone() } else { folder.path.join("/") }; if several { format!("{}/{path}", job.label) } else { path } }) .collect::>(); let request = MailImportRequest { source: job.source, folders: job.folders, destination, parent_folder: parent, loose_folder_name: loose_folder_name.clone(), own_addresses: own_addresses.clone(), }; let (job_progress, mut job_receiver) = watch::channel(MailImportProgress::default()); let task = { let core = core.clone(); let stop = Arc::clone(&stop); tokio::spawn(async move { core.import_mail(request, job_progress, stop).await }) }; // The import drops its sender when it ends. let imported_before = outcome.imported; while job_receiver.changed().await.is_ok() { let job_progress = *job_receiver.borrow_and_update(); progress.send_replace(Progress { mail: true, done_bytes: done_bytes + job_progress.done_bytes, total_bytes, imported: imported_before + job_progress.imported, folder: job_progress .folder .and_then(|index| names.get(index).cloned()) .unwrap_or_default(), }); } done_bytes += job.bytes; let report = match task.await { Ok(Ok(report)) => report, Ok(Err(error)) => { outcome.error = Some(error); break; } Err(error) => { outcome.error = Some(error.to_string()); break; } }; outcome.imported += report.imported; outcome.already_present += report.already_present; outcome.too_large += report.too_large; outcome.refused += report.refused; if outcome.refusal.is_none() { outcome.refusal = report.refusal; } outcome.folders.extend(report.folders); for (path, error) in report.unreadable { let mut name = path.join("/"); if several { name = if name.is_empty() { job.label.clone() } else { format!("{}/{name}", job.label) }; } outcome.unreadable.push((name, error)); } if report.stopped { outcome.stopped = true; break; } } outcome } fn show_progress(app: &AppWindow, progress: Progress) { let ui = app.global::(); if ui.get_stage() != "importing" || !progress.mail { return; } ui.set_mail_progress(if progress.total_bytes == 0 { 0.0 } else { (progress.done_bytes as f64 / progress.total_bytes as f64) as f32 }); ui.set_mail_progress_count(i32::try_from(progress.imported).unwrap_or(i32::MAX)); ui.set_mail_progress_done(format_storage_size(progress.done_bytes).into()); ui.set_mail_progress_total(format_storage_size(progress.total_bytes).into()); ui.set_mail_progress_folder(progress.folder.into()); } fn ui_count(value: u64) -> i32 { i32::try_from(value).unwrap_or(i32::MAX) } fn show_mail_report(app: &AppWindow, outcome: &MailOutcome) { let ui = app.global::(); ui.set_mail_results(true); ui.set_mail_imported(ui_count(outcome.imported)); ui.set_mail_already_present(ui_count(outcome.already_present)); ui.set_mail_too_large(ui_count(outcome.too_large)); ui.set_mail_refused(ui_count(outcome.refused)); ui.set_mail_refusal(outcome.refusal.clone().unwrap_or_default().into()); ui.set_mail_stopped(outcome.stopped); ui.set_mail_failures(ModelRc::new(VecModel::from( outcome .unreadable .iter() .map(|(folder, error)| ImportFailureRow { email: folder.as_str().into(), error: error.as_str().into(), }) .collect::>(), ))); } fn show_report(app: &AppWindow, report: &AccountImportReport) { let ui = app.global::(); ui.set_account_results(true); ui.set_imported(report.imported.len() as i32); ui.set_already_present(report.already_present.len() as i32); ui.set_restored_preferences(false); ui.set_failures(ModelRc::new(VecModel::from( report .failed .iter() .map(|failed| ImportFailureRow { email: failed.email.as_str().into(), error: failed.error.as_str().into(), }) .collect::>(), ))); } /// Clear the results of an earlier import from the dialog. fn clear_results(app: &AppWindow) { let ui = app.global::(); ui.set_account_results(false); ui.set_imported(0); ui.set_already_present(0); ui.set_restored_preferences(false); ui.set_failures(ModelRc::default()); ui.set_mail_results(false); ui.set_mail_imported(0); ui.set_mail_already_present(0); ui.set_mail_too_large(0); ui.set_mail_refused(0); ui.set_mail_refusal(slint::SharedString::default()); ui.set_mail_stopped(false); ui.set_mail_failures(ModelRc::default()); } /// Reloads accounts after an import so the sidebar and settings list them /// with their imported colors. fn refresh_accounts( app: &AppWindow, core: &CoreMailSource, state: &Rc>, runtime: &tokio::runtime::Runtime, ) { match runtime.block_on(core.load_settings()) { Ok(settings) => { state.borrow_mut().account_presentation = AccountPresentationSettings::from_settings(&settings); } Err(error) => tracing::warn!(%error, "reloading settings after import failed"), } match runtime.block_on(core.load_account_snapshot()) { Ok(snapshot) => { update_connected_accounts(app, state, runtime, snapshot.accounts, snapshot.configs); refresh_rows_only(app, state, runtime); } Err(error) => tracing::warn!(%error, "reloading accounts after import failed"), } } /// The name of a chosen file or folder, for the mail it holds. Files /// chosen together are named by their folder when they sit in it under their /// own names. A phone's copies, each in a folder of its own or under a /// generated name, name nothing. fn source_label(files: &[ChosenFile]) -> String { let in_place = |file: &ChosenFile| file.path.file_name() == Some(std::ffi::OsStr::new(&file.name)); match files { [file] => file.name.clone(), [first, rest @ ..] if files.iter().all(in_place) => first .path .parent() .filter(|parent| rest.iter().all(|file| file.path.parent() == Some(*parent))) .and_then(|parent| parent.file_name()) .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(), _ => String::new(), } } pub(super) fn register_import_callbacks( app: &AppWindow, documents: &std::sync::Arc, state: &Rc>, runtime: &Rc, ) { let session = Rc::new(RefCell::new(ImportSession::default())); app.global::() .set_folder_supported(cfg!(desktop)); // Reads a file or folder off the UI thread, then plans it against the // accounts this device already has. `label` names mail found without // accounts; `documents` are kept while their mail may be imported. type Inspect = dyn Fn(SourceReader, String, Vec); let inspect: Rc = { let app = app.as_weak(); let session = Rc::clone(&session); let state = Rc::clone(state); let runtime = Rc::clone(runtime); Rc::new( move |read: SourceReader, label: String, documents: Vec| { let Some(app) = app.upgrade() else { return; }; let Some(core) = state.borrow().core.clone() else { show_storage_unavailable(&app); return; }; let generation = { let mut session = session.borrow_mut(); session.clear_source(); session.documents = documents; session.advance() }; app.global::().set_stage("reading".into()); let request: documents::DocumentFuture> = Box::pin(async move { let source = tokio::task::spawn_blocking(read) .await .map_err(|error| error.to_string())?; let inventory = match source { Ok(Source::Accounts(inventory)) => inventory, Ok(Source::FlectarBackup(bytes)) => { return Ok(Ok(Inspected::Backup(bytes))); } Ok(Source::Mail(mail)) => return Ok(Ok(Inspected::Mail(mail))), Err(error) => return Ok(Err(source_error(error))), }; let existing = core .load_accounts() .await? .into_iter() .map(|account| account.email) .collect::>(); Ok(Ok(Inspected::Plans { source: inventory.source, plans: migration::plan(&inventory, &existing), other: inventory .other_entries .iter() .map(|entry| entry.kind) .collect(), own_addresses: inventory .accounts .iter() .flat_map(|account| &account.identities) .map(|identity| identity.email.clone()) .collect(), mail: inventory.mail, })) }); let app_weak = app.as_weak(); let session = Rc::clone(&session); let state = Rc::clone(&state); let runtime_for_restore = Rc::clone(&runtime); documents::run_request(&runtime, request, move |result| { let Some(app) = app_weak.upgrade() else { return; }; if session.borrow().generation != generation { return; } let inspected = match result { Ok(Ok(inspected)) => inspected, Ok(Err((code, detail))) => return show_error(&app, code, detail), Err(error) => return show_error(&app, "failed", error), }; let ui = app.global::(); match inspected { Inspected::Backup(bytes) => { let Some(core) = state.borrow().core.clone() else { return show_storage_unavailable(&app); }; match restore_account_backup( &app, &bytes, &core, &state, &runtime_for_restore, ) { Ok(restore) => { clear_results(&app); ui.set_account_results(true); ui.set_imported(restore.imported as i32); ui.set_already_present(restore.already_present as i32); ui.set_restored_preferences(restore.preferences); ui.set_stage("done".into()); } Err(error) => { // A failure part way may still have added // accounts; show whatever exists now. refresh_accounts(&app, &core, &state, &runtime_for_restore); show_error(&app, "restore-failed", error); } } } Inspected::Plans { source, plans, other, mail, own_addresses, } => { let count = |kind| other.iter().filter(|entry| **entry == kind).count() as i32; ui.set_local_folders(count(OtherEntryKind::LocalFolders)); ui.set_feeds(count(OtherEntryKind::Feeds)); ui.set_newsgroups(count(OtherEntryKind::Newsgroups)); let mut session = session.borrow_mut(); // Every importable account starts selected, as in // Thunderbird's and K-9's own import screens. session.selected = plans .iter() .map(|plan| plan.status == PlanStatus::Ready) .collect(); session.plans = plans; session.source = Some(source); session.mail = mail.into_iter().map(MailChoice::from_profile).collect(); session.own_addresses = own_addresses; if session.plans.is_empty() { let accounts = state.borrow().connected_accounts.clone(); show_mail(&app, &mut session, &accounts); } else { show_review(&app, &session); } } Inspected::Mail(mail) => { ui.set_local_folders(0); ui.set_feeds(0); ui.set_newsgroups(0); ui.set_selected_count(0); let mut session = session.borrow_mut(); session.mail = vec![MailChoice::new(MailStore { account: None, label, mail, })]; let accounts = state.borrow().connected_accounts.clone(); show_mail(&app, &mut session, &accounts); } } }); }, ) }; let app_weak = app.as_weak(); let open_session = Rc::clone(&session); let open_runtime = Rc::clone(runtime); app.on_import_accounts(move || { let Some(app) = app_weak.upgrade() else { return; }; // Focus can reach the entry points behind the dialog; reopening must // not discard an import in progress or its report. One going on in // the background is shown again. let ui = app.global::(); if ui.get_open() { return; } if ui.get_in_background() { ui.set_open(true); return; } let opening = { let mut session = open_session.borrow_mut(); session.advance(); session.opening += 1; session.opening }; ui.set_stage("choose".into()); ui.set_accounts(ModelRc::default()); ui.set_profiles(ModelRc::default()); ui.set_has_mail(false); ui.set_mail_rows(ModelRc::default()); clear_results(&app); ui.set_open(true); #[cfg(desktop)] { let app_weak = app.as_weak(); let session = Rc::clone(&open_session); let request: documents::DocumentFuture> = Box::pin(async { tokio::task::spawn_blocking(migration::thunderbird::discover_profiles) .await .map_err(|error| error.to_string()) }); documents::run_request(&open_runtime, request, move |profiles| { let (Some(app), Ok(profiles)) = (app_weak.upgrade(), profiles) else { return; }; let mut session = session.borrow_mut(); if session.opening != opening { return; } app.global::() .set_profiles(ModelRc::new(VecModel::from( profiles .iter() .map(|profile| ImportProfileRow { name: profile.name.as_str().into(), path: profile.path.display().to_string().into(), is_default: profile.is_default, }) .collect::>(), ))); session.profiles = profiles; }); } #[cfg(not(desktop))] let _ = (&open_runtime, opening); }); let app_weak = app.as_weak(); let file_documents = documents.clone(); let file_runtime = Rc::clone(runtime); let file_inspect = Rc::clone(&inspect); let file_session = Rc::clone(&session); app.global::().on_choose_file(move || { let Some(app) = app_weak.upgrade() else { return; }; let Some(opening) = start_picking(&app, &file_session) else { return; }; // Saved mail often has no extension, so every file is offered. let dialog = documents::DocumentDialog::titled( translated(&app, &UiMessage::plain("Import accounts and mail")).to_string(), ); let inspect = Rc::clone(&file_inspect); let session = Rc::clone(&file_session); let app_weak = app.as_weak(); documents::run_request( &file_runtime, file_documents.import_many(MAX_CHOSEN_FILES, dialog), move |picked| { let Some(app) = app_weak.upgrade() else { return; }; if !finish_picking(&app, &session, opening) { return; } match picked { Ok(documents) if documents.is_empty() => {} // Several files can only be mail. Ok(documents) => { let files = documents .iter() .map(|document| ChosenFile { path: document.path().to_owned(), name: document.name().unwrap_or_default(), }) .collect::>(); let mut label = source_label(&files); if label.is_empty() { label = translated(&app, &UiMessage::plain("Imported messages")) .to_string(); } let read: SourceReader = if files.len() == 1 { let file = files[0].clone(); Box::new(move || migration::open_chosen(&file)) } else { Box::new(move || mail_import::scan_files(&files).map(Source::Mail)) }; inspect(read, label, documents); } Err(error) => show_error(&app, "unreadable", error), } }, ); }); #[cfg(desktop)] { let app_weak = app.as_weak(); let folder_runtime = Rc::clone(runtime); let folder_inspect = Rc::clone(&inspect); let folder_session = Rc::clone(&session); app.global::().on_choose_folder(move || { let Some(app) = app_weak.upgrade() else { return; }; let Some(opening) = start_picking(&app, &folder_session) else { return; }; let title = translated( &app, &UiMessage::plain("Choose a Thunderbird profile or a folder of mail"), ); let request: documents::DocumentFuture> = Box::pin(async move { Ok(rfd::AsyncFileDialog::new() .set_title(title.as_str()) .pick_folder() .await .map(|folder| folder.path().to_owned())) }); let inspect = Rc::clone(&folder_inspect); let session = Rc::clone(&folder_session); let app_weak = app.as_weak(); documents::run_request(&folder_runtime, request, move |picked| { let Some(app) = app_weak.upgrade() else { return; }; if !finish_picking(&app, &session, opening) { return; } match picked { Ok(Some(folder)) => { let label = source_label(&[ChosenFile::at(folder.clone())]); inspect( Box::new(move || migration::open(&folder)), label, Vec::new(), ); } Ok(None) => {} Err(error) => show_error(&app, "unreadable", error), } }); }); let app_weak = app.as_weak(); let profile_session = Rc::clone(&session); let profile_inspect = Rc::clone(&inspect); app.global::() .on_choose_profile(move |index| { let Some(app) = app_weak.upgrade() else { return; }; let profile = { let session = profile_session.borrow(); if !can_choose(&app, &session) { return; } usize::try_from(index) .ok() .and_then(|index| session.profiles.get(index).cloned()) }; if let Some(profile) = profile { let path = profile.path; profile_inspect( Box::new(move || migration::open(&path)), profile.name, Vec::new(), ); } }); } let app_weak = app.as_weak(); let toggle_session = Rc::clone(&session); app.global::() .on_toggle(move |index, checked| { let Some(app) = app_weak.upgrade() else { return; }; let mut session = toggle_session.borrow_mut(); let Some(index) = usize::try_from(index) .ok() .filter(|index| *index < session.plans.len()) else { return; }; let checked = checked && session.plans[index].status == PlanStatus::Ready; session.selected[index] = checked; let ui = app.global::(); let rows = ui.get_accounts(); if let Some(mut row) = rows.row_data(index) { row.selected = checked; rows.set_row_data(index, row); } ui.set_selected_count( session .selected .iter() .filter(|selected| **selected) .count() as i32, ); }); let app_weak = app.as_weak(); let next_session = Rc::clone(&session); let next_state = Rc::clone(state); app.global::().on_next(move || { let Some(app) = app_weak.upgrade() else { return; }; let mut session = next_session.borrow_mut(); if app.global::().get_stage() != "review" || session.mail.is_empty() { return; } let accounts = next_state.borrow().connected_accounts.clone(); show_mail(&app, &mut session, &accounts); }); let app_weak = app.as_weak(); let mail_toggle_session = Rc::clone(&session); app.global::() .on_toggle_mail(move |row, checked| { let Some(app) = app_weak.upgrade() else { return; }; let mut session = mail_toggle_session.borrow_mut(); let Some(&(store, folder)) = usize::try_from(row) .ok() .and_then(|row| session.rows.get(row)) else { return; }; let choice = &mut session.mail[store]; // A folder is chosen with its subfolders. let range = match folder { Some(folder) => folder..choice.subfolders(folder).end, None => 0..choice.folders().len(), }; choice.selected[range].fill(checked); show_mail_rows(&app, &mut session); }); let app_weak = app.as_weak(); let destination_session = Rc::clone(&session); app.global::() .on_choose_destination(move |row, destination| { let Some(app) = app_weak.upgrade() else { return; }; let mut session = destination_session.borrow_mut(); let Some(destination) = usize::try_from(destination) .ok() .filter(|destination| *destination < session.destinations.len()) else { return; }; let Some(&(store, None)) = usize::try_from(row) .ok() .and_then(|row| session.rows.get(row)) else { return; }; session.mail[store].destination = destination; show_mail_rows(&app, &mut session); }); let app_weak = app.as_weak(); let confirm_session = Rc::clone(&session); let confirm_state = Rc::clone(state); let confirm_runtime = Rc::clone(runtime); app.global::().on_confirm(move || { let Some(app) = app_weak.upgrade() else { return; }; // A second activation while importing must not import twice. let stage = app.global::().get_stage(); if stage != "review" && stage != "mail" { return; } let Some(core) = confirm_state.borrow().core.clone() else { return show_storage_unavailable(&app); }; let (drafts, jobs, own_addresses, stop, generation) = { let mut session = confirm_session.borrow_mut(); let drafts = session.selected_drafts(); // Mail is chosen on its own step, after the accounts. let jobs = if stage == "mail" { mail_jobs(&session) } else { Vec::new() }; if drafts.is_empty() && jobs.is_empty() { return; } let stop = Arc::new(AtomicBool::new(false)); session.stop = Some(Arc::clone(&stop)); session.imported_folder = None; ( drafts, jobs, session.own_addresses.clone(), stop, session.advance(), ) }; let has_accounts = !drafts.is_empty(); let has_mail = !jobs.is_empty(); clear_results(&app); let ui = app.global::(); ui.set_stopping(false); ui.set_mail_progress(if has_accounts { -1.0 } else { 0.0 }); ui.set_mail_progress_count(0); ui.set_mail_progress_folder(slint::SharedString::default()); ui.set_mail_progress_done(format_storage_size(0).into()); ui.set_mail_progress_total( format_storage_size(jobs.iter().map(|job| job.bytes).sum()).into(), ); ui.set_stage("importing".into()); // Progress reaches the dialog at most ten times a second. let (progress, mut progress_receiver) = watch::channel(Progress::default()); let progress_app = app.as_weak(); confirm_runtime.spawn(async move { while progress_receiver.changed().await.is_ok() { let progress = progress_receiver.borrow_and_update().clone(); let _ = progress_app.upgrade_in_event_loop(move |app| show_progress(&app, progress)); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } }); let loose_folder_name = translated(&app, &UiMessage::plain("Imported messages")).to_string(); let import_core = core.clone(); let request: documents::DocumentFuture = Box::pin(async move { let accounts = if drafts.is_empty() { None } else { Some(import_core.import_accounts(drafts).await?) }; let mail = if jobs.is_empty() { None } else { Some( import_mail( import_core, jobs, own_addresses, loose_folder_name, progress, stop, ) .await, ) }; Ok(ImportOutcome { accounts, mail }) }); let app_weak = app.as_weak(); let session = Rc::clone(&confirm_session); let state = Rc::clone(&confirm_state); let runtime = Rc::clone(&confirm_runtime); documents::run_request(&confirm_runtime, request, move |result| { let Some(app) = app_weak.upgrade() else { return; }; // Accounts and mail may exist even if the import failed part way. if has_accounts { refresh_accounts(&app, &core, &state, &runtime); } if has_mail { refresh_local_mail(&app, &state, &runtime); } let mut session = session.borrow_mut(); session.stop = None; if session.generation != generation { return; } let outcome = match result { Ok(outcome) => outcome, Err(error) => return show_error(&app, "failed", error), }; if let Some(report) = &outcome.accounts { show_report(&app, report); } if let Some(mail) = &outcome.mail { session.imported_folder = mail.folders.first().copied(); if let Some(error) = &mail.error { return show_error(&app, "mail-failed", error.clone()); } show_mail_report(&app, mail); } app.global::().set_stage("done".into()); }); }); let app_weak = app.as_weak(); let stop_session = Rc::clone(&session); app.global::().on_stop(move || { let Some(app) = app_weak.upgrade() else { return; }; if let Some(stop) = &stop_session.borrow().stop { stop.store(true, Ordering::Relaxed); app.global::().set_stopping(true); } }); let app_weak = app.as_weak(); let show_session = Rc::clone(&session); app.global::().on_show_mail(move || { let Some(app) = app_weak.upgrade() else { return; }; let folder = show_session.borrow().imported_folder; app.global::().invoke_close(); if let Some(folder) = folder { app.invoke_select_scope(Scope::Folder(folder).to_string().into()); } }); let app_weak = app.as_weak(); let back_session = Rc::clone(&session); app.global::().on_back(move || { let Some(app) = app_weak.upgrade() else { return; }; let mut session = back_session.borrow_mut(); session.advance(); let ui = app.global::(); // From the mail step, back to the accounts it follows. if ui.get_stage() == "mail" && !session.plans.is_empty() { show_review(&app, &session); return; } session.clear_source(); ui.set_has_mail(false); ui.set_mail_rows(ModelRc::default()); ui.set_stage("choose".into()); }); let app_weak = app.as_weak(); let close_session = Rc::clone(&session); app.global::().on_close(move || { let Some(app) = app_weak.upgrade() else { return; }; let mut session = close_session.borrow_mut(); let generation = session.advance(); *session = ImportSession { generation, opening: session.opening + 1, ..ImportSession::default() }; let ui = app.global::(); ui.set_open(false); ui.set_in_background(false); ui.set_accounts(ModelRc::default()); ui.set_mail_rows(ModelRc::default()); ui.set_destinations(ModelRc::default()); ui.set_failures(ModelRc::default()); ui.set_mail_failures(ModelRc::default()); }); } #[cfg(test)] mod tests { use super::*; use flectar_mail_core::models::{AuthKind, Provider}; const MESSAGE: &str = "From a@example.org Mon Jan 1 00:00:00 2024\n\ From: a@example.org\nSubject: Hi\n\nHello\n"; /// A Thunderbird store whose Archives holds only a subfolder. fn store(account: Option<&str>) -> (tempfile::TempDir, MailStore) { let directory = tempfile::tempdir().unwrap(); for (name, contents) in [ ("Inbox", MESSAGE), ("Archives", ""), ("Archives.sbd/2023", MESSAGE), ("Junk", MESSAGE), ("Trash", MESSAGE), ] { let path = directory.path().join(name); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, contents).unwrap(); } let mail = mail_import::scan_folder(directory.path()).unwrap(); let store = MailStore { account: account.map(str::to_owned), label: "Local Folders".into(), mail, }; (directory, store) } fn account(id: i64, email: &str, mail_protocol: MailProtocol) -> Account { Account { id, email: email.into(), display_name: None, avatar_url: None, provider: Provider::Generic, auth_kind: AuthKind::Password, mail_protocol, sync_state: "idle".into(), sync_error: None, can_create_top_level_mailbox: true, server_keeps_mail_days: None, } } #[test] fn folders_are_chosen_with_their_subfolders_but_not_trash_or_junk() { let (_directory, store) = store(None); let mut choice = MailChoice::new(store); let names = choice .folders() .iter() .map(|folder| folder.path.join("/")) .collect::>(); assert_eq!( names, ["Archives", "Archives/2023", "Inbox", "Junk", "Trash"] ); assert_eq!(choice.chosen().collect::>(), [1, 2]); // Some of the store's folders are chosen, not all. assert_eq!(choice.state(0..5), (false, true)); // Archives has no messages of its own; its subfolder speaks for it. assert_eq!(choice.subfolders(0), 1..2); assert_eq!(choice.state(choice.subfolders(0)), (true, false)); assert_eq!(choice.subfolders(2), 3..3); assert_eq!(choice.subfolders(4), 5..5); let archives = 0..choice.subfolders(0).end; choice.selected[archives].fill(false); assert_eq!(choice.chosen().collect::>(), [2]); assert_eq!(choice.state(choice.subfolders(0)), (false, false)); choice.selected.fill(true); assert_eq!(choice.state(0..5), (true, false)); } #[test] fn chosen_mail_goes_where_its_store_says() { let (_local_directory, local) = store(None); let (_pop_directory, pop) = store(Some("account1")); let accounts = [ account(1, "pop@example.com", MailProtocol::Pop3), account(2, "imap@example.com", MailProtocol::Imap), ]; let mut session = ImportSession { mail: vec![MailChoice::new(local), MailChoice::from_profile(pop)], ..ImportSession::default() }; session.destinations = mail_destinations(&session, &accounts); let pop3 = MailAccount { email: "pop@example.com".into(), protocol: MailProtocol::Pop3, }; let imap = MailAccount { email: "imap@example.com".into(), protocol: MailProtocol::Imap, }; assert_eq!( session.destinations, [None, Some(pop3.clone()), Some(imap.clone())] ); // Only an IMAP account's mail is uploaded to its server. assert!(!pop3.uploads() && imap.uploads()); // Without a plan for its account, a store stays on this device. assert_eq!( default_destination(&session, &session.destinations, &session.mail[1].store), 0 ); session.mail[1].destination = 1; let jobs = mail_jobs(&session); assert_eq!(jobs.len(), 2); assert_eq!(jobs[0].folders, [1, 2]); let folders = session.mail[0].folders(); assert_eq!(jobs[0].bytes, folders[1].size + folders[2].size); assert_eq!(jobs[0].destination, None); assert_eq!(jobs[0].parent, None); assert_eq!(jobs[0].upload_folder, None); // An account's mail goes in a folder of its own if it stays here. assert_eq!(jobs[1].destination, Some(pop3)); assert_eq!(jobs[1].parent.as_deref(), Some("Local Folders")); // On a server, a profile's mail goes in a folder named after it. session.mail[1].destination = 2; let jobs = mail_jobs(&session); assert_eq!(jobs[1].destination, Some(imap)); assert_eq!(jobs[1].upload_folder.as_deref(), Some("Local Folders")); // A store with nothing chosen is left out. session.mail[0].selected.fill(false); assert_eq!(mail_jobs(&session).len(), 1); } #[test] fn destinations_are_named_by_address() { let destinations = [ None, Some(MailAccount { email: "ana@example.com".into(), protocol: MailProtocol::Pop3, }), Some(MailAccount { email: "Ana@example.com".into(), protocol: MailProtocol::Imap, }), Some(MailAccount { email: "bo@example.com".into(), protocol: MailProtocol::Jmap, }), ]; let names = (0..destinations.len()) .map(|index| destination_name(&destinations, index, "On this device")) .collect::>(); assert_eq!( names, [ "On this device", "ana@example.com (POP3)", "Ana@example.com (IMAP)", "bo@example.com" ] ); } #[test] fn mail_without_accounts_is_named_after_what_was_chosen() { let chosen = |path: &str| ChosenFile::at(PathBuf::from(path)); assert_eq!( source_label(&[chosen("/home/me/Mail/Inbox.mbox")]), "Inbox.mbox" ); assert_eq!( source_label(&[ chosen("/home/me/Saved/a.eml"), chosen("/home/me/Saved/b.eml") ]), "Saved" ); // Phones copy picked files into one folder under generated names, and // keep the picked ones. let picked = |path: &str, name: &str| ChosenFile { path: PathBuf::from(path), name: name.into(), }; assert_eq!( source_label(&[picked("/cache/file-imports/0b7c", "Work.mbox")]), "Work.mbox" ); assert_eq!( source_label(&[ picked("/cache/file-imports/0b7c", "a.eml"), picked("/cache/file-imports/9f21", "b.eml"), ]), "" ); assert_eq!(source_label(&[]), ""); } }