diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e522e0a1..7199274ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixes - Start SQL transactions with IMMEDIATE behaviour rather than default DEFERRED one. #4063 +- Fix a problem with Gmail where (auto-)deleted messages would get archived instead of deleted. + Move them to the Trash folder for Gmail which auto-deletes trashed messages in 30 days #3972 ### API-Changes diff --git a/python/tests/test_1_online.py b/python/tests/test_1_online.py index b38afe88c..c25fcb85b 100644 --- a/python/tests/test_1_online.py +++ b/python/tests/test_1_online.py @@ -1954,6 +1954,7 @@ def test_immediate_autodelete(acfactory, lp): assert msg.text == "hello" lp.sec("ac2: wait for close/expunge on autodelete") + ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_DELETED") ac2._evtracker.get_info_contains("close/expunge succeeded") lp.sec("ac2: check that message was autodeleted on server") @@ -1995,6 +1996,34 @@ def test_delete_multiple_messages(acfactory, lp): assert len(ac2.direct_imap.get_all_messages()) == 1 +def test_trash_multiple_messages(acfactory, lp): + ac1, ac2 = acfactory.get_online_accounts(2) + ac2.set_config("delete_to_trash", "1") + chat12 = acfactory.get_accepted_chat(ac1, ac2) + + lp.sec("ac1: sending 3 messages") + texts = ["first", "second", "third"] + for text in texts: + chat12.send_text(text) + + lp.sec("ac2: waiting for all messages on the other side") + to_delete = [] + for text in texts: + msg = ac2._evtracker.wait_next_incoming_message() + assert msg.text in texts + if text != "second": + to_delete.append(msg) + + lp.sec("ac2: deleting all messages except second") + assert len(to_delete) == len(texts) - 1 + ac2.delete_messages(to_delete) + ac2._evtracker.get_matching("DC_EVENT_IMAP_MESSAGE_MOVED") + + lp.sec("ac2: test that only one message is left") + ac2.direct_imap.select_config_folder("inbox") + assert len(ac2.direct_imap.get_all_messages()) == 1 + + def test_configure_error_msgs_wrong_pw(acfactory): configdict = acfactory.get_next_liveconfig() ac1 = acfactory.get_unconfigured_account() diff --git a/src/config.rs b/src/config.rs index 67d77811f..8c1357a8e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,7 @@ //! # Key-value configuration management. +use std::str::FromStr; + use anyhow::{ensure, Context as _, Result}; use strum::{EnumProperty, IntoEnumIterator}; use strum_macros::{AsRefStr, Display, EnumIter, EnumProperty, EnumString}; @@ -173,6 +175,10 @@ pub enum Config { #[strum(props(default = "0"))] DeleteDeviceAfter, + /// Move messages to the Trash folder instead of marking them "\Deleted". Overrides + /// `ProviderOptions::delete_to_trash`. + DeleteToTrash, + /// Save raw MIME messages with headers in the database if true. SaveMimeHeaders, @@ -227,6 +233,9 @@ pub enum Config { /// Configured "Sent" folder. ConfiguredSentboxFolder, + /// Configured "Trash" folder. + ConfiguredTrashFolder, + /// Unix timestamp of the last successful configuration. ConfiguredTimestamp, @@ -327,30 +336,37 @@ impl Context { } } - /// Returns 32-bit signed integer configuration value for the given key. - pub async fn get_config_int(&self, key: Config) -> Result { + /// Returns Some(T) if a value for the given key exists and was successfully parsed. + /// Returns None if could not parse. + pub async fn get_config_parsed(&self, key: Config) -> Result> { self.get_config(key) .await - .map(|s: Option| s.and_then(|s| s.parse().ok()).unwrap_or_default()) + .map(|s: Option| s.and_then(|s| s.parse().ok())) + } + + /// Returns 32-bit signed integer configuration value for the given key. + pub async fn get_config_int(&self, key: Config) -> Result { + Ok(self.get_config_parsed(key).await?.unwrap_or_default()) } /// Returns 64-bit signed integer configuration value for the given key. pub async fn get_config_i64(&self, key: Config) -> Result { - self.get_config(key) - .await - .map(|s: Option| s.and_then(|s| s.parse().ok()).unwrap_or_default()) + Ok(self.get_config_parsed(key).await?.unwrap_or_default()) } /// Returns 64-bit unsigned integer configuration value for the given key. pub async fn get_config_u64(&self, key: Config) -> Result { - self.get_config(key) - .await - .map(|s: Option| s.and_then(|s| s.parse().ok()).unwrap_or_default()) + Ok(self.get_config_parsed(key).await?.unwrap_or_default()) + } + + /// Returns boolean configuration value (if any) for the given key. + pub async fn get_config_bool_opt(&self, key: Config) -> Result> { + Ok(self.get_config_parsed::(key).await?.map(|x| x != 0)) } /// Returns boolean configuration value for the given key. pub async fn get_config_bool(&self, key: Config) -> Result { - Ok(self.get_config_int(key).await? != 0) + Ok(self.get_config_bool_opt(key).await?.unwrap_or_default()) } /// Returns true if movebox ("DeltaChat" folder) should be watched. @@ -550,7 +566,6 @@ fn get_config_keys_string() -> String { #[cfg(test)] mod tests { - use std::str::FromStr; use std::string::ToString; use num_traits::FromPrimitive; diff --git a/src/constants.rs b/src/constants.rs index 2775f97ef..4c19abba6 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -201,7 +201,7 @@ pub const BALANCED_IMAGE_SIZE: u32 = 1280; pub const WORSE_IMAGE_SIZE: u32 = 640; // this value can be increased if the folder configuration is changed and must be redone on next program start -pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 3; +pub(crate) const DC_FOLDERS_CONFIGURED_VERSION: i32 = 4; #[cfg(test)] mod tests { diff --git a/src/context.rs b/src/context.rs index c672a1171..04687ad0c 100644 --- a/src/context.rs +++ b/src/context.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; -use anyhow::{bail, ensure, Result}; +use anyhow::{bail, ensure, Context as _, Result}; use async_channel::{self as channel, Receiver, Sender}; use ratelimit::Ratelimit; use tokio::sync::{Mutex, RwLock}; @@ -623,6 +623,10 @@ impl Context { .get_config(Config::ConfiguredMvboxFolder) .await? .unwrap_or_else(|| "".to_string()); + let configured_trash_folder = self + .get_config(Config::ConfiguredTrashFolder) + .await? + .unwrap_or_else(|| "".to_string()); let mut res = get_info(); @@ -689,6 +693,7 @@ impl Context { res.insert("configured_inbox_folder", configured_inbox_folder); res.insert("configured_sentbox_folder", configured_sentbox_folder); res.insert("configured_mvbox_folder", configured_mvbox_folder); + res.insert("configured_trash_folder", configured_trash_folder); res.insert("mdns_enabled", mdns_enabled.to_string()); res.insert("e2ee_enabled", e2ee_enabled.to_string()); res.insert( @@ -722,6 +727,12 @@ impl Context { .await? .to_string(), ); + res.insert( + "delete_to_trash", + self.get_config(Config::DeleteToTrash) + .await? + .unwrap_or_else(|| "".to_string()), + ); res.insert( "last_housekeeping", self.get_config_int(Config::LastHousekeeping) @@ -887,6 +898,33 @@ impl Context { Ok(mvbox.as_deref() == Some(folder_name)) } + /// Returns true if given folder name is the name of the trash folder. + pub async fn is_trash(&self, folder_name: &str) -> Result { + let trash = self.get_config(Config::ConfiguredTrashFolder).await?; + Ok(trash.as_deref() == Some(folder_name)) + } + + pub(crate) async fn should_delete_to_trash(&self) -> Result { + if let Some(v) = self.get_config_bool_opt(Config::DeleteToTrash).await? { + return Ok(v); + } + if let Some(provider) = self.get_configured_provider().await? { + return Ok(provider.opt.delete_to_trash); + } + Ok(false) + } + + /// Returns `target` for deleted messages as per `imap` table. Empty string means "delete w/o + /// moving to trash". + pub(crate) async fn get_delete_msgs_target(&self) -> Result { + if !self.should_delete_to_trash().await? { + return Ok("".into()); + } + self.get_config(Config::ConfiguredTrashFolder) + .await? + .context("No configured trash folder") + } + pub(crate) fn derive_blobdir(dbfile: &Path) -> PathBuf { let mut blob_fname = OsString::new(); blob_fname.push(dbfile.file_name().unwrap_or_default()); diff --git a/src/download.rs b/src/download.rs index fb56f729c..5b544fe31 100644 --- a/src/download.rs +++ b/src/download.rs @@ -138,7 +138,7 @@ impl Job { context .sql .query_row_optional( - "SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target!=''", + "SELECT uid, folder FROM imap WHERE rfc724_mid=? AND target=folder", paramsv![msg.rfc724_mid], |row| { let server_uid: u32 = row.get(0)?; diff --git a/src/ephemeral.rs b/src/ephemeral.rs index 2e38386ea..606c7270f 100644 --- a/src/ephemeral.rs +++ b/src/ephemeral.rs @@ -588,19 +588,25 @@ pub(crate) async fn delete_expired_imap_messages(context: &Context) -> Result<() now - max(delete_server_after, MIN_DELETE_SERVER_AFTER), ), }; + let target = context.get_delete_msgs_target().await?; context .sql .execute( "UPDATE imap - SET target='' + SET target=? WHERE rfc724_mid IN ( SELECT rfc724_mid FROM msgs WHERE ((download_state = 0 AND timestamp < ?) OR (download_state != 0 AND timestamp < ?) OR (ephemeral_timestamp != 0 AND ephemeral_timestamp <= ?)) )", - paramsv![threshold_timestamp, threshold_timestamp_extended, now], + paramsv![ + target, + threshold_timestamp, + threshold_timestamp_extended, + now, + ], ) .await?; diff --git a/src/imap.rs b/src/imap.rs index 56a2cc6e6..5cdecae9b 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -113,13 +113,15 @@ impl async_imap::Authenticator for OAuth2 { } } -#[derive(Debug, PartialEq, Clone, Copy)] -enum FolderMeaning { +#[derive(Debug, Display, PartialEq, Eq, Clone, Copy)] +pub enum FolderMeaning { Unknown, Spam, + Inbox, + Mvbox, Sent, + Trash, Drafts, - Other, /// Virtual folders. /// @@ -131,13 +133,15 @@ enum FolderMeaning { } impl FolderMeaning { - fn to_config(self) -> Option { + pub fn to_config(self) -> Option { match self { FolderMeaning::Unknown => None, FolderMeaning::Spam => None, + FolderMeaning::Inbox => Some(Config::ConfiguredInboxFolder), + FolderMeaning::Mvbox => Some(Config::ConfiguredMvboxFolder), FolderMeaning::Sent => Some(Config::ConfiguredSentboxFolder), + FolderMeaning::Trash => Some(Config::ConfiguredTrashFolder), FolderMeaning::Drafts => None, - FolderMeaning::Other => None, FolderMeaning::Virtual => None, } } @@ -449,7 +453,7 @@ impl Imap { &mut self, context: &Context, watch_folder: &str, - is_spam_folder: bool, + folder_meaning: FolderMeaning, ) -> Result<()> { if !context.sql.is_open().await { // probably shutdown @@ -458,7 +462,7 @@ impl Imap { self.prepare(context).await?; let msgs_fetched = self - .fetch_new_messages(context, watch_folder, is_spam_folder, false) + .fetch_new_messages(context, watch_folder, folder_meaning, false) .await .context("fetch_new_messages")?; if msgs_fetched && context.get_config_delete_device_after().await?.is_some() { @@ -490,49 +494,60 @@ impl Imap { pub(crate) async fn resync_folder_uids( &mut self, context: &Context, - folder: String, + folder: &str, + folder_meaning: FolderMeaning, ) -> Result<()> { // Collect pairs of UID and Message-ID. - let mut msg_ids = BTreeMap::new(); + let mut msgs = BTreeMap::new(); let session = self .session .as_mut() .context("IMAP No connection established")?; - session.select_folder(context, Some(&folder)).await?; + session.select_folder(context, Some(folder)).await?; let mut list = session .uid_fetch("1:*", RFC724MID_UID) .await .with_context(|| format!("can't resync folder {folder}"))?; while let Some(fetch) = list.next().await { - let msg = fetch?; + let fetch = fetch?; + let headers = match get_fetch_headers(&fetch) { + Ok(headers) => headers, + Err(err) => { + warn!(context, "Failed to parse FETCH headers: {}", err); + continue; + } + }; + let message_id = prefetch_get_message_id(&headers); - // Get Message-ID - let message_id = - get_fetch_headers(&msg).map_or(None, |headers| prefetch_get_message_id(&headers)); - - if let (Some(uid), Some(rfc724_mid)) = (msg.uid, message_id) { - msg_ids.insert(uid, rfc724_mid); + if let (Some(uid), Some(rfc724_mid)) = (fetch.uid, message_id) { + msgs.insert( + uid, + ( + rfc724_mid, + target_folder(context, folder, folder_meaning, &headers).await?, + ), + ); } } info!( context, "Resync: collected {} message IDs in folder {}", - msg_ids.len(), - &folder + msgs.len(), + folder, ); - let uid_validity = get_uidvalidity(context, &folder).await?; + let uid_validity = get_uidvalidity(context, folder).await?; // Write collected UIDs to SQLite database. context .sql .transaction(move |transaction| { transaction.execute("DELETE FROM imap WHERE folder=?", params![folder])?; - for (uid, rfc724_mid) in &msg_ids { + for (uid, (rfc724_mid, target)) in &msgs { // This may detect previously undetected moved // messages, so we update server_folder too. transaction.execute( @@ -541,7 +556,7 @@ impl Imap { ON CONFLICT(folder, uid, uidvalidity) DO UPDATE SET rfc724_mid=excluded.rfc724_mid, target=excluded.target", - params![rfc724_mid, folder, uid, uid_validity, folder], + params![rfc724_mid, folder, uid, uid_validity, target], )?; } Ok(()) @@ -683,10 +698,10 @@ impl Imap { &mut self, context: &Context, folder: &str, - is_spam_folder: bool, + folder_meaning: FolderMeaning, fetch_existing_msgs: bool, ) -> Result { - if should_ignore_folder(context, folder, is_spam_folder).await? { + if should_ignore_folder(context, folder, folder_meaning).await? { info!(context, "Not fetching from {}", folder); return Ok(false); } @@ -732,14 +747,7 @@ impl Imap { // Get the Message-ID or generate a fake one to identify the message in the database. let message_id = prefetch_get_or_create_message_id(&headers); - - let target = match target_folder(context, folder, is_spam_folder, &headers).await? { - Some(config) => match context.get_config(config).await? { - Some(target) => target, - None => folder.to_string(), - }, - None => folder.to_string(), - }; + let target = target_folder(context, folder, folder_meaning, &headers).await?; context .sql @@ -763,8 +771,8 @@ impl Imap { // Never download messages directly from the spam folder. // If the sender is known, the message will be moved to the Inbox or Mvbox // and then we download the message from there. - // Also see `spam_target_folder()`. - && !is_spam_folder + // Also see `spam_target_folder_cfg()`. + && folder_meaning != FolderMeaning::Spam && prefetch_should_download( context, &headers, @@ -870,17 +878,21 @@ impl Imap { .context("failed to get recipients from the inbox")?; if context.get_config_bool(Config::FetchExistingMsgs).await? { - for config in &[ - Config::ConfiguredMvboxFolder, - Config::ConfiguredInboxFolder, - Config::ConfiguredSentboxFolder, + for meaning in [ + FolderMeaning::Mvbox, + FolderMeaning::Inbox, + FolderMeaning::Sent, ] { - if let Some(folder) = context.get_config(*config).await? { + let config = match meaning.to_config() { + Some(c) => c, + None => continue, + }; + if let Some(folder) = context.get_config(config).await? { info!( context, "Fetching existing messages from folder \"{}\"", folder ); - self.fetch_new_messages(context, &folder, false, true) + self.fetch_new_messages(context, &folder, meaning, true) .await .context("could not fetch existing messages")?; } @@ -952,44 +964,60 @@ impl Session { return Ok(()); } Err(err) => { + if context.should_delete_to_trash().await? { + error!( + context, + "Cannot move messages {} to {}, no fallback to COPY/DELETE because \ + delete_to_trash is set. Error: {:#}", + set, + target, + err, + ); + return Err(err.into()); + } warn!( context, - "Cannot move message, fallback to COPY/DELETE {} to {}: {}", + "Cannot move messages, fallback to COPY/DELETE {} to {}: {}", set, target, err ); } } - } else { + } + + // Server does not support MOVE or MOVE failed. + // Copy messages to the destination folder if needed and mark records for deletion. + let copy = !context.is_trash(target).await?; + if copy { info!( context, "Server does not support MOVE, fallback to COPY/DELETE {} to {}", set, target ); + self.uid_copy(&set, &target).await?; + } else { + error!( + context, + "Server does not support MOVE, fallback to DELETE {} to {}", set, target, + ); } - - // Server does not support MOVE or MOVE failed. - // Copy the message to the destination folder and mark the record for deletion. - match self.uid_copy(&set, &target).await { - Ok(()) => { - context - .sql - .execute( - &format!( - "UPDATE imap SET target='' WHERE id IN ({})", - sql::repeat_vars(row_ids.len()) - ), - rusqlite::params_from_iter(row_ids), - ) - .await - .context("cannot plan deletion of copied messages")?; - context.emit_event(EventType::ImapMessageMoved(format!( - "IMAP messages {set} copied to {target}" - ))); - Ok(()) - } - Err(err) => Err(err.into()), + context + .sql + .execute( + &format!( + "UPDATE imap SET target='' WHERE id IN ({})", + sql::repeat_vars(row_ids.len()) + ), + rusqlite::params_from_iter(row_ids), + ) + .await + .context("cannot plan deletion of messages")?; + if copy { + context.emit_event(EventType::ImapMessageMoved(format!( + "IMAP messages {set} copied to {target}" + ))); } + Ok(()) } /// Moves and deletes messages as planned in the `imap` table. @@ -1644,7 +1672,7 @@ impl Imap { } } - let folder_meaning = get_folder_meaning(&folder); + let folder_meaning = get_folder_meaning_by_attrs(folder.attributes()); let folder_name_meaning = get_folder_meaning_by_name(folder.name()); if let Some(config) = folder_meaning.to_config() { // Always takes precedence @@ -1776,7 +1804,7 @@ async fn should_move_out_of_spam( /// If this returns None, the message will not be moved out of the /// Spam folder, and as `fetch_new_messages()` doesn't download /// messages from the Spam folder, the message will be ignored. -async fn spam_target_folder( +async fn spam_target_folder_cfg( context: &Context, headers: &[mailparse::MailHeader<'_>], ) -> Result> { @@ -1797,18 +1825,18 @@ async fn spam_target_folder( /// Returns `ConfiguredInboxFolder`, `ConfiguredMvboxFolder` or `ConfiguredSentboxFolder` if /// the message needs to be moved from `folder`. Otherwise returns `None`. -pub async fn target_folder( +pub async fn target_folder_cfg( context: &Context, folder: &str, - is_spam_folder: bool, + folder_meaning: FolderMeaning, headers: &[mailparse::MailHeader<'_>], ) -> Result> { if context.is_mvbox(folder).await? { return Ok(None); } - if is_spam_folder { - spam_target_folder(context, headers).await + if folder_meaning == FolderMeaning::Spam { + spam_target_folder_cfg(context, headers).await } else if needs_move_to_mvbox(context, headers).await? { Ok(Some(Config::ConfiguredMvboxFolder)) } else { @@ -1816,6 +1844,21 @@ pub async fn target_folder( } } +pub async fn target_folder( + context: &Context, + folder: &str, + folder_meaning: FolderMeaning, + headers: &[mailparse::MailHeader<'_>], +) -> Result { + match target_folder_cfg(context, folder, folder_meaning, headers).await? { + Some(config) => match context.get_config(config).await? { + Some(target) => Ok(target), + None => Ok(folder.to_string()), + }, + None => Ok(folder.to_string()), + } +} + async fn needs_move_to_mvbox( context: &Context, headers: &[mailparse::MailHeader<'_>], @@ -1940,10 +1983,10 @@ fn get_folder_meaning_by_name(folder_name: &str) -> FolderMeaning { } } -fn get_folder_meaning(folder_name: &Name) -> FolderMeaning { - for attr in folder_name.attributes() { +fn get_folder_meaning_by_attrs(folder_attrs: &[NameAttribute]) -> FolderMeaning { + for attr in folder_attrs { match attr { - NameAttribute::Trash => return FolderMeaning::Other, + NameAttribute::Trash => return FolderMeaning::Trash, NameAttribute::Sent => return FolderMeaning::Sent, NameAttribute::Junk => return FolderMeaning::Spam, NameAttribute::Drafts => return FolderMeaning::Drafts, @@ -1961,6 +2004,13 @@ fn get_folder_meaning(folder_name: &Name) -> FolderMeaning { FolderMeaning::Unknown } +pub(crate) fn get_folder_meaning(folder: &Name) -> FolderMeaning { + match get_folder_meaning_by_attrs(folder.attributes()) { + FolderMeaning::Unknown => get_folder_meaning_by_name(folder.name()), + meaning => meaning, + } +} + /// Parses the headers from the FETCH result. fn get_fetch_headers(prefetch_msg: &Fetch) -> Result> { match prefetch_msg.header() { @@ -2272,7 +2322,7 @@ pub async fn get_config_last_seen_uid(context: &Context, folder: &str) -> Result async fn should_ignore_folder( context: &Context, folder: &str, - is_spam_folder: bool, + folder_meaning: FolderMeaning, ) -> Result { if !context.get_config_bool(Config::OnlyFetchMvbox).await? { return Ok(false); @@ -2281,7 +2331,7 @@ async fn should_ignore_folder( // Still respect the SentboxWatch setting. return Ok(!context.get_config_bool(Config::SentboxWatch).await?); } - Ok(!(context.is_mvbox(folder).await? || is_spam_folder)) + Ok(!(context.is_mvbox(folder).await? || folder_meaning == FolderMeaning::Spam)) } /// Builds a list of sequence/uid sets. The returned sets have each no more than around 1000 @@ -2564,14 +2614,13 @@ mod tests { }; let (headers, _) = mailparse::parse_headers(bytes)?; - - let is_spam_folder = folder == "Spam"; - let actual = - if let Some(config) = target_folder(&t, folder, is_spam_folder, &headers).await? { - t.get_config(config).await? - } else { - None - }; + let actual = if let Some(config) = + target_folder_cfg(&t, folder, get_folder_meaning_by_name(folder), &headers).await? + { + t.get_config(config).await? + } else { + None + }; let expected = if expected_destination == folder { None diff --git a/src/imap/idle.rs b/src/imap/idle.rs index 4c0910fbe..fbce499b7 100644 --- a/src/imap/idle.rs +++ b/src/imap/idle.rs @@ -7,7 +7,7 @@ use futures_lite::FutureExt; use super::session::Session; use super::Imap; -use crate::imap::client::IMAP_TIMEOUT; +use crate::imap::{client::IMAP_TIMEOUT, FolderMeaning}; use crate::{context::Context, scheduler::InterruptInfo}; const IDLE_TIMEOUT: Duration = Duration::from_secs(23 * 60); @@ -113,6 +113,7 @@ impl Imap { &mut self, context: &Context, watch_folder: Option, + folder_meaning: FolderMeaning, ) -> InterruptInfo { // Idle using polling. This is also needed if we're not yet configured - // in this case, we're waiting for a configure job (and an interrupt). @@ -173,7 +174,7 @@ impl Imap { // will have already fetched the messages so perform_*_fetch // will not find any new. match self - .fetch_new_messages(context, &watch_folder, false, false) + .fetch_new_messages(context, &watch_folder, folder_meaning, false) .await { Ok(res) => { diff --git a/src/imap/scan_folders.rs b/src/imap/scan_folders.rs index 991b6b38f..29051e7b9 100644 --- a/src/imap/scan_folders.rs +++ b/src/imap/scan_folders.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, time::Instant}; use anyhow::{Context as _, Result}; use futures::stream::StreamExt; -use super::{get_folder_meaning, get_folder_meaning_by_name}; +use super::{get_folder_meaning_by_attrs, get_folder_meaning_by_name}; use crate::config::Config; use crate::imap::Imap; use crate::log::LogExt; @@ -33,7 +33,7 @@ impl Imap { let mut folder_configs = BTreeMap::new(); for folder in folders { - let folder_meaning = get_folder_meaning(&folder); + let folder_meaning = get_folder_meaning_by_attrs(folder.attributes()); if folder_meaning == FolderMeaning::Virtual { // Gmail has virtual folders that should be skipped. For example, // emails appear in the inbox and under "All Mail" as soon as it is @@ -53,21 +53,22 @@ impl Imap { .or_insert_with(|| folder.name().to_string()); } - let is_drafts = folder_meaning == FolderMeaning::Drafts - || (folder_meaning == FolderMeaning::Unknown - && folder_name_meaning == FolderMeaning::Drafts); - let is_spam_folder = folder_meaning == FolderMeaning::Spam - || (folder_meaning == FolderMeaning::Unknown - && folder_name_meaning == FolderMeaning::Spam); + let folder_meaning = match folder_meaning { + FolderMeaning::Unknown => folder_name_meaning, + _ => folder_meaning, + }; // Don't scan folders that are watched anyway - if !watched_folders.contains(&folder.name().to_string()) && !is_drafts { + if !watched_folders.contains(&folder.name().to_string()) + && folder_meaning != FolderMeaning::Drafts + && folder_meaning != FolderMeaning::Trash + { let session = self.session.as_mut().context("no session")?; // Drain leftover unsolicited EXISTS messages session.server_sent_unsolicited_exists(context)?; loop { - self.fetch_move_delete(context, folder.name(), is_spam_folder) + self.fetch_move_delete(context, folder.name(), folder_meaning) .await .ok_or_log_msg(context, "Can't fetch new msgs in scanned folder"); @@ -80,15 +81,15 @@ impl Imap { } } - // Set the `ConfiguredSentboxFolder` or set it to `None` if the folder was deleted. - context - .set_config( - Config::ConfiguredSentboxFolder, - folder_configs - .get(&Config::ConfiguredSentboxFolder) - .map(|s| s.as_str()), - ) - .await?; + // Set configs for necessary folders. Or reset if the folder was deleted. + for conf in [ + Config::ConfiguredSentboxFolder, + Config::ConfiguredTrashFolder, + ] { + context + .set_config(conf, folder_configs.get(&conf).map(|s| s.as_str())) + .await?; + } last_scan.replace(Instant::now()); Ok(true) diff --git a/src/job.rs b/src/job.rs index 51c06d372..3279843a9 100644 --- a/src/job.rs +++ b/src/job.rs @@ -12,7 +12,7 @@ use deltachat_derive::{FromSql, ToSql}; use rand::{thread_rng, Rng}; use crate::context::Context; -use crate::imap::Imap; +use crate::imap::{get_folder_meaning, FolderMeaning, Imap}; use crate::param::Params; use crate::scheduler::InterruptInfo; use crate::tools::time; @@ -172,8 +172,12 @@ impl Job { let mut any_failed = false; for folder in all_folders { + let folder_meaning = get_folder_meaning(&folder); + if folder_meaning == FolderMeaning::Virtual { + continue; + } if let Err(e) = imap - .resync_folder_uids(context, folder.name().to_string()) + .resync_folder_uids(context, folder.name(), folder_meaning) .await { warn!(context, "{:#}", e); diff --git a/src/message.rs b/src/message.rs index e4bae9a67..a7b2e27e3 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1386,11 +1386,12 @@ pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> { context.emit_event(EventType::WebxdcInstanceDeleted { msg_id: *msg_id }); } + let target = context.get_delete_msgs_target().await?; context .sql .execute( - "UPDATE imap SET target='' WHERE rfc724_mid=?", - paramsv![msg.rfc724_mid], + "UPDATE imap SET target=? WHERE rfc724_mid=?", + paramsv![target, msg.rfc724_mid], ) .await?; diff --git a/src/provider.rs b/src/provider.rs index 565d54d10..b461e600c 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -138,6 +138,16 @@ pub struct Provider { /// Type of OAuth 2 authorization if provider supports it. pub oauth2_authorizer: Option, + + /// Options with good defaults. + pub opt: ProviderOptions, +} + +/// Provider options with good defaults. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ProviderOptions { + /// Move messages to the Trash folder instead of marking them "\Deleted". + pub delete_to_trash: bool, } /// Get resolver to query MX records. diff --git a/src/provider/data.rs b/src/provider/data.rs index 21084f821..49a0d30a9 100644 --- a/src/provider/data.rs +++ b/src/provider/data.rs @@ -7,7 +7,9 @@ use once_cell::sync::Lazy; use crate::provider::Protocol::*; use crate::provider::Socket::*; use crate::provider::UsernamePattern::*; -use crate::provider::{Config, ConfigDefault, Oauth2Authorizer, Provider, Server, Status}; +use crate::provider::{ + Config, ConfigDefault, Oauth2Authorizer, Provider, ProviderOptions, Server, Status, +}; // 163.md: 163.com static P_163: Lazy = Lazy::new(|| Provider { @@ -36,6 +38,7 @@ static P_163: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // aktivix.org.md: aktivix.org @@ -65,6 +68,7 @@ static P_AKTIVIX_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // aol.md: aol.com @@ -83,6 +87,7 @@ static P_AOL: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -113,6 +118,7 @@ static P_ARCOR_DE: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // autistici.org.md: autistici.org @@ -142,6 +148,7 @@ static P_AUTISTICI_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // blindzeln.org.md: delta.blinzeln.de, delta.blindzeln.org @@ -171,6 +178,7 @@ static P_BLINDZELN_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // bluewin.ch.md: bluewin.ch @@ -200,6 +208,7 @@ static P_BLUEWIN_CH: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // buzon.uy.md: buzon.uy @@ -229,6 +238,7 @@ static P_BUZON_UY: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // chello.at.md: chello.at @@ -258,6 +268,7 @@ static P_CHELLO_AT: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // comcast.md: xfinity.com, comcast.net @@ -272,6 +283,7 @@ static P_COMCAST: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // dismail.de.md: dismail.de @@ -286,6 +298,7 @@ static P_DISMAIL_DE: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // disroot.md: disroot.org @@ -315,6 +328,7 @@ static P_DISROOT: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // e.email.md: e.email @@ -344,6 +358,7 @@ static P_E_EMAIL: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // espiv.net.md: espiv.net @@ -358,6 +373,7 @@ static P_ESPIV_NET: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // example.com.md: example.com, example.org, example.net @@ -376,6 +392,7 @@ static P_EXAMPLE_COM: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -407,6 +424,7 @@ static P_FASTMAIL: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // firemail.de.md: firemail.at, firemail.de @@ -423,6 +441,7 @@ static P_FIREMAIL_DE: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -451,6 +470,7 @@ static P_FIVE_CHAT: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // freenet.de.md: freenet.de @@ -469,6 +489,7 @@ static P_FREENET_DE: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -488,6 +509,9 @@ static P_GMAIL: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: Some(Oauth2Authorizer::Gmail), + opt: ProviderOptions { + delete_to_trash: true, + }, } }); @@ -525,6 +549,7 @@ static P_GMX_NET: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // hermes.radio.md: ac.hermes.radio, ac1.hermes.radio, ac2.hermes.radio, ac3.hermes.radio, ac4.hermes.radio, ac5.hermes.radio, ac6.hermes.radio, ac7.hermes.radio, ac8.hermes.radio, ac9.hermes.radio, ac10.hermes.radio, ac11.hermes.radio, ac12.hermes.radio, ac13.hermes.radio, ac14.hermes.radio, ac15.hermes.radio, ka.hermes.radio, ka1.hermes.radio, ka2.hermes.radio, ka3.hermes.radio, ka4.hermes.radio, ka5.hermes.radio, ka6.hermes.radio, ka7.hermes.radio, ka8.hermes.radio, ka9.hermes.radio, ka10.hermes.radio, ka11.hermes.radio, ka12.hermes.radio, ka13.hermes.radio, ka14.hermes.radio, ka15.hermes.radio, ec.hermes.radio, ec1.hermes.radio, ec2.hermes.radio, ec3.hermes.radio, ec4.hermes.radio, ec5.hermes.radio, ec6.hermes.radio, ec7.hermes.radio, ec8.hermes.radio, ec9.hermes.radio, ec10.hermes.radio, ec11.hermes.radio, ec12.hermes.radio, ec13.hermes.radio, ec14.hermes.radio, ec15.hermes.radio, hermes.radio @@ -552,6 +577,7 @@ static P_HERMES_RADIO: Lazy = Lazy::new(|| Provider { strict_tls: false, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // hey.com.md: hey.com @@ -568,6 +594,7 @@ static P_HEY_COM: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -583,6 +610,7 @@ static P_I_UA: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // i3.net.md: i3.net @@ -597,6 +625,7 @@ static P_I3_NET: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // icloud.md: icloud.com, me.com, mac.com @@ -626,6 +655,7 @@ static P_ICLOUD: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // infomaniak.com.md: ik.me @@ -655,6 +685,7 @@ static P_INFOMANIAK_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: Some(10), oauth2_authorizer: None, + opt: Default::default(), }); // kolst.com.md: kolst.com @@ -669,6 +700,7 @@ static P_KOLST_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // kontent.com.md: kontent.com @@ -683,6 +715,7 @@ static P_KONTENT_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // mail.de.md: mail.de @@ -712,6 +745,7 @@ static P_MAIL_DE: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // mail.ru.md: mail.ru, inbox.ru, internet.ru, bk.ru, list.ru @@ -730,6 +764,7 @@ static P_MAIL_RU: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -760,6 +795,7 @@ static P_MAIL2TOR: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // mailbox.org.md: mailbox.org, secure.mailbox.org @@ -789,6 +825,7 @@ static P_MAILBOX_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // mailo.com.md: mailo.com @@ -818,6 +855,7 @@ static P_MAILO_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // nauta.cu.md: nauta.cu @@ -872,6 +910,7 @@ static P_NAUTA_CU: Lazy = Lazy::new(|| Provider { strict_tls: false, max_smtp_rcpt_to: Some(20), oauth2_authorizer: None, + opt: Default::default(), }); // naver.md: naver.com @@ -901,6 +940,7 @@ static P_NAVER: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // nubo.coop.md: nubo.coop @@ -930,6 +970,7 @@ static P_NUBO_COOP: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // outlook.com.md: hotmail.com, outlook.com, office365.com, outlook.com.tr, live.com, outlook.de @@ -959,6 +1000,7 @@ static P_OUTLOOK_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // ouvaton.coop.md: ouvaton.org @@ -988,6 +1030,7 @@ static P_OUVATON_COOP: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // posteo.md: posteo.de, posteo.af, posteo.at, posteo.be, posteo.ca, posteo.ch, posteo.cl, posteo.co, posteo.co.uk, posteo.com.br, posteo.cr, posteo.cz, posteo.dk, posteo.ee, posteo.es, posteo.eu, posteo.fi, posteo.gl, posteo.gr, posteo.hn, posteo.hr, posteo.hu, posteo.ie, posteo.in, posteo.is, posteo.it, posteo.jp, posteo.la, posteo.li, posteo.lt, posteo.lu, posteo.me, posteo.mx, posteo.my, posteo.net, posteo.nl, posteo.no, posteo.nz, posteo.org, posteo.pe, posteo.pl, posteo.pm, posteo.pt, posteo.ro, posteo.ru, posteo.se, posteo.sg, posteo.si, posteo.tn, posteo.uk, posteo.us @@ -1017,6 +1060,7 @@ static P_POSTEO: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // protonmail.md: protonmail.com, protonmail.ch, pm.me @@ -1033,6 +1077,7 @@ static P_PROTONMAIL: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1052,6 +1097,7 @@ static P_QQ: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1082,6 +1128,7 @@ static P_RISEUP_NET: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // rogers.com.md: rogers.com @@ -1096,6 +1143,7 @@ static P_ROGERS_COM: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // systemausfall.org.md: systemausfall.org, solidaris.me @@ -1125,6 +1173,7 @@ static P_SYSTEMAUSFALL_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // systemli.org.md: systemli.org @@ -1154,6 +1203,7 @@ static P_SYSTEMLI_ORG: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // t-online.md: t-online.de, magenta.de @@ -1172,6 +1222,7 @@ static P_T_ONLINE: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1222,6 +1273,7 @@ static P_TESTRUN: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // tiscali.it.md: tiscali.it @@ -1251,6 +1303,7 @@ static P_TISCALI_IT: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // tutanota.md: tutanota.com, tutanota.de, tutamail.com, tuta.io, keemail.me @@ -1267,6 +1320,7 @@ static P_TUTANOTA: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1282,6 +1336,7 @@ static P_UKR_NET: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // undernet.uy.md: undernet.uy @@ -1311,6 +1366,7 @@ static P_UNDERNET_UY: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // vfemail.md: vfemail.net @@ -1325,6 +1381,7 @@ static P_VFEMAIL: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // vivaldi.md: vivaldi.net @@ -1354,6 +1411,7 @@ static P_VIVALDI: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // vodafone.de.md: vodafone.de, vodafonemail.de @@ -1383,6 +1441,7 @@ static P_VODAFONE_DE: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // web.de.md: web.de, email.de, flirt.ms, hallo.ms, kuss.ms, love.ms, magic.ms, singles.ms, cool.ms, kanzler.ms, okay.ms, party.ms, pop.ms, stars.ms, techno.ms, clever.ms, deutschland.ms, genial.ms, ich.ms, online.ms, smart.ms, wichtig.ms, action.ms, fussball.ms, joker.ms, planet.ms, power.ms @@ -1402,6 +1461,7 @@ static P_WEB_DE: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1421,6 +1481,7 @@ static P_YAHOO: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1451,6 +1512,7 @@ static P_YANDEX_RU: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: Some(Oauth2Authorizer::Yandex), + opt: Default::default(), }); // yggmail.md: yggmail @@ -1471,6 +1533,7 @@ static P_YGGMAIL: Lazy = Lazy::new(|| { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), } }); @@ -1501,6 +1564,7 @@ static P_ZIGGO_NL: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); // zoho.md: zohomail.eu, zohomail.com, zoho.com @@ -1530,6 +1594,7 @@ static P_ZOHO: Lazy = Lazy::new(|| Provider { strict_tls: true, max_smtp_rcpt_to: None, oauth2_authorizer: None, + opt: Default::default(), }); pub(crate) static PROVIDER_DATA: Lazy> = Lazy::new(|| { diff --git a/src/receive_imf.rs b/src/receive_imf.rs index 95db04d9c..1896e844b 100644 --- a/src/receive_imf.rs +++ b/src/receive_imf.rs @@ -342,11 +342,12 @@ pub(crate) async fn receive_imf_inner( if received_msg.needs_delete_job || (delete_server_after == Some(0) && is_partial_download.is_none()) { + let target = context.get_delete_msgs_target().await?; context .sql .execute( - "UPDATE imap SET target='' WHERE rfc724_mid=?", - paramsv![rfc724_mid], + "UPDATE imap SET target=? WHERE rfc724_mid=?", + paramsv![target, rfc724_mid], ) .await?; } else if !mime_parser.mdn_reports.is_empty() && mime_parser.has_chat_version() { diff --git a/src/scheduler.rs b/src/scheduler.rs index 356c40e07..df9312c4e 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,6 +1,8 @@ +use std::iter::{self, once}; + use anyhow::{bail, Context as _, Result}; use async_channel::{self as channel, Receiver, Sender}; -use futures::try_join; +use futures::future::try_join_all; use futures_lite::FutureExt; use tokio::task; @@ -9,7 +11,7 @@ use crate::config::Config; use crate::contact::{ContactId, RecentlySeenLoop}; use crate::context::Context; use crate::ephemeral::{self, delete_expired_imap_messages}; -use crate::imap::Imap; +use crate::imap::{FolderMeaning, Imap}; use crate::job; use crate::location; use crate::log::LogExt; @@ -20,15 +22,19 @@ use crate::tools::{duration_to_str, maybe_add_time_based_warnings}; pub(crate) mod connectivity; +#[derive(Debug)] +struct SchedBox { + meaning: FolderMeaning, + conn_state: ImapConnectionState, + handle: task::JoinHandle<()>, +} + /// Job and connection scheduler. #[derive(Debug)] pub(crate) struct Scheduler { - inbox: ImapConnectionState, - inbox_handle: task::JoinHandle<()>, - mvbox: ImapConnectionState, - mvbox_handle: Option>, - sentbox: ImapConnectionState, - sentbox_handle: Option>, + inbox: SchedBox, + /// Optional boxes -- mvbox, sentbox. + oboxes: Vec, smtp: SmtpConnectionState, smtp_handle: task::JoinHandle<()>, ephemeral_handle: task::JoinHandle<()>, @@ -161,7 +167,7 @@ async fn inbox_loop(ctx: Context, started: Sender<()>, inbox_handlers: ImapConne } } - info = fetch_idle(&ctx, &mut connection, Config::ConfiguredInboxFolder).await; + info = fetch_idle(&ctx, &mut connection, FolderMeaning::Inbox).await; } } } @@ -182,7 +188,20 @@ async fn inbox_loop(ctx: Context, started: Sender<()>, inbox_handlers: ImapConne /// handling all the errors. In case of an error, it is logged, but not propagated upwards. If /// critical operation fails such as fetching new messages fails, connection is reset via /// `trigger_reconnect`, so a fresh one can be opened. -async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) -> InterruptInfo { +async fn fetch_idle( + ctx: &Context, + connection: &mut Imap, + folder_meaning: FolderMeaning, +) -> InterruptInfo { + let folder_config = match folder_meaning.to_config() { + Some(c) => c, + None => { + error!(ctx, "Bad folder meaning: {}", folder_meaning); + return connection + .fake_idle(ctx, None, FolderMeaning::Unknown) + .await; + } + }; let folder = match ctx.get_config(folder_config).await { Ok(folder) => folder, Err(err) => { @@ -190,7 +209,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) ctx, "Can not watch {} folder, failed to retrieve config: {:#}", folder_config, err ); - return connection.fake_idle(ctx, None).await; + return connection + .fake_idle(ctx, None, FolderMeaning::Unknown) + .await; } }; @@ -199,7 +220,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) } else { connection.connectivity.set_not_configured(ctx).await; info!(ctx, "Can not watch {} folder, not set", folder_config); - return connection.fake_idle(ctx, None).await; + return connection + .fake_idle(ctx, None, FolderMeaning::Unknown) + .await; }; // connect and fake idle if unable to connect @@ -210,7 +233,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) { warn!(ctx, "{:#}", err); connection.trigger_reconnect(ctx); - return connection.fake_idle(ctx, Some(watch_folder)).await; + return connection + .fake_idle(ctx, Some(watch_folder), folder_meaning) + .await; } if folder_config == Config::ConfiguredInboxFolder { @@ -227,7 +252,7 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) // Fetch the watched folder. if let Err(err) = connection - .fetch_move_delete(ctx, &watch_folder, false) + .fetch_move_delete(ctx, &watch_folder, folder_meaning) .await .context("fetch_move_delete") { @@ -265,7 +290,7 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) // no new messages. We want to select the watched folder anyway before going IDLE // there, so this does not take additional protocol round-trip. if let Err(err) = connection - .fetch_move_delete(ctx, &watch_folder, false) + .fetch_move_delete(ctx, &watch_folder, folder_meaning) .await .context("fetch_move_delete after scan_folders") { @@ -293,7 +318,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) ctx, "IMAP session does not support IDLE, going to fake idle." ); - return connection.fake_idle(ctx, Some(watch_folder)).await; + return connection + .fake_idle(ctx, Some(watch_folder), folder_meaning) + .await; } info!(ctx, "IMAP session supports IDLE, using it."); @@ -318,7 +345,9 @@ async fn fetch_idle(ctx: &Context, connection: &mut Imap, folder_config: Config) } } else { warn!(ctx, "No IMAP session, going to fake idle."); - connection.fake_idle(ctx, Some(watch_folder)).await + connection + .fake_idle(ctx, Some(watch_folder), folder_meaning) + .await } } @@ -326,11 +355,11 @@ async fn simple_imap_loop( ctx: Context, started: Sender<()>, inbox_handlers: ImapConnectionHandlers, - folder_config: Config, + folder_meaning: FolderMeaning, ) { use futures::future::FutureExt; - info!(ctx, "starting simple loop for {}", folder_config); + info!(ctx, "starting simple loop for {}", folder_meaning); let ImapConnectionHandlers { mut connection, stop_receiver, @@ -346,7 +375,7 @@ async fn simple_imap_loop( } loop { - fetch_idle(&ctx, &mut connection, folder_config).await; + fetch_idle(&ctx, &mut connection, folder_meaning).await; } }; @@ -443,75 +472,56 @@ async fn smtp_loop(ctx: Context, started: Sender<()>, smtp_handlers: SmtpConnect impl Scheduler { /// Start the scheduler. pub async fn start(ctx: Context) -> Result { - let (mvbox, mvbox_handlers) = ImapConnectionState::new(&ctx).await?; - let (sentbox, sentbox_handlers) = ImapConnectionState::new(&ctx).await?; let (smtp, smtp_handlers) = SmtpConnectionState::new(); - let (inbox, inbox_handlers) = ImapConnectionState::new(&ctx).await?; - let (inbox_start_send, inbox_start_recv) = channel::bounded(1); - let (mvbox_start_send, mvbox_start_recv) = channel::bounded(1); - let mut mvbox_handle = None; - let (sentbox_start_send, sentbox_start_recv) = channel::bounded(1); - let mut sentbox_handle = None; let (smtp_start_send, smtp_start_recv) = channel::bounded(1); let (ephemeral_interrupt_send, ephemeral_interrupt_recv) = channel::bounded(1); let (location_interrupt_send, location_interrupt_recv) = channel::bounded(1); - let inbox_handle = { + let mut oboxes = Vec::new(); + let mut start_recvs = Vec::new(); + + let (conn_state, inbox_handlers) = ImapConnectionState::new(&ctx).await?; + let (inbox_start_send, inbox_start_recv) = channel::bounded(1); + let handle = { let ctx = ctx.clone(); task::spawn(async move { inbox_loop(ctx, inbox_start_send, inbox_handlers).await }) }; + let inbox = SchedBox { + meaning: FolderMeaning::Inbox, + conn_state, + handle, + }; + start_recvs.push(inbox_start_recv); - if ctx.should_watch_mvbox().await? { - let ctx = ctx.clone(); - mvbox_handle = Some(task::spawn(async move { - simple_imap_loop( - ctx, - mvbox_start_send, - mvbox_handlers, - Config::ConfiguredMvboxFolder, - ) - .await - })); - } else { - mvbox_start_send - .send(()) - .await - .context("mvbox start send, missing receiver")?; - mvbox_handlers - .connection - .connectivity - .set_not_configured(&ctx) - .await - } - - if ctx.get_config_bool(Config::SentboxWatch).await? { - let ctx = ctx.clone(); - sentbox_handle = Some(task::spawn(async move { - simple_imap_loop( - ctx, - sentbox_start_send, - sentbox_handlers, - Config::ConfiguredSentboxFolder, - ) - .await - })); - } else { - sentbox_start_send - .send(()) - .await - .context("sentbox start send, missing receiver")?; - sentbox_handlers - .connection - .connectivity - .set_not_configured(&ctx) - .await + for (meaning, should_watch) in [ + (FolderMeaning::Mvbox, ctx.should_watch_mvbox().await), + ( + FolderMeaning::Sent, + ctx.get_config_bool(Config::SentboxWatch).await, + ), + ] { + if should_watch? { + let (conn_state, handlers) = ImapConnectionState::new(&ctx).await?; + let (start_send, start_recv) = channel::bounded(1); + let ctx = ctx.clone(); + let handle = task::spawn(async move { + simple_imap_loop(ctx, start_send, handlers, meaning).await + }); + oboxes.push(SchedBox { + meaning, + conn_state, + handle, + }); + start_recvs.push(start_recv); + } } let smtp_handle = { let ctx = ctx.clone(); task::spawn(async move { smtp_loop(ctx, smtp_start_send, smtp_handlers).await }) }; + start_recvs.push(smtp_start_recv); let ephemeral_handle = { let ctx = ctx.clone(); @@ -531,12 +541,8 @@ impl Scheduler { let res = Self { inbox, - mvbox, - sentbox, + oboxes, smtp, - inbox_handle, - mvbox_handle, - sentbox_handle, smtp_handle, ephemeral_handle, ephemeral_interrupt_send, @@ -546,12 +552,7 @@ impl Scheduler { }; // wait for all loops to be started - if let Err(err) = try_join!( - inbox_start_recv.recv(), - mvbox_start_recv.recv(), - sentbox_start_recv.recv(), - smtp_start_recv.recv() - ) { + if let Err(err) = try_join_all(start_recvs.iter().map(|r| r.recv())).await { bail!("failed to start scheduler: {}", err); } @@ -559,30 +560,26 @@ impl Scheduler { Ok(res) } + fn boxes(&self) -> iter::Chain, std::slice::Iter<'_, SchedBox>> { + once(&self.inbox).chain(self.oboxes.iter()) + } + fn maybe_network(&self) { - self.interrupt_inbox(InterruptInfo::new(true)); - self.interrupt_mvbox(InterruptInfo::new(true)); - self.interrupt_sentbox(InterruptInfo::new(true)); + for b in self.boxes() { + b.conn_state.interrupt(InterruptInfo::new(true)); + } self.interrupt_smtp(InterruptInfo::new(true)); } fn maybe_network_lost(&self) { - self.interrupt_inbox(InterruptInfo::new(false)); - self.interrupt_mvbox(InterruptInfo::new(false)); - self.interrupt_sentbox(InterruptInfo::new(false)); + for b in self.boxes() { + b.conn_state.interrupt(InterruptInfo::new(false)); + } self.interrupt_smtp(InterruptInfo::new(false)); } fn interrupt_inbox(&self, info: InterruptInfo) { - self.inbox.interrupt(info); - } - - fn interrupt_mvbox(&self, info: InterruptInfo) { - self.mvbox.interrupt(info); - } - - fn interrupt_sentbox(&self, info: InterruptInfo) { - self.sentbox.interrupt(info); + self.inbox.conn_state.interrupt(info); } fn interrupt_smtp(&self, info: InterruptInfo) { @@ -605,29 +602,17 @@ impl Scheduler { /// /// It consumes the scheduler and never fails to stop it. In the worst case, long-running tasks /// are forcefully terminated if they cannot shutdown within the timeout. - pub(crate) async fn stop(mut self, context: &Context) { + pub(crate) async fn stop(self, context: &Context) { // Send stop signals to tasks so they can shutdown cleanly. - self.inbox.stop().await.ok_or_log(context); - if self.mvbox_handle.is_some() { - self.mvbox.stop().await.ok_or_log(context); - } - if self.sentbox_handle.is_some() { - self.sentbox.stop().await.ok_or_log(context); + for b in self.boxes() { + b.conn_state.stop().await.ok_or_log(context); } self.smtp.stop().await.ok_or_log(context); // Actually shutdown tasks. let timeout_duration = std::time::Duration::from_secs(30); - tokio::time::timeout(timeout_duration, self.inbox_handle) - .await - .ok_or_log(context); - if let Some(mvbox_handle) = self.mvbox_handle.take() { - tokio::time::timeout(timeout_duration, mvbox_handle) - .await - .ok_or_log(context); - } - if let Some(sentbox_handle) = self.sentbox_handle.take() { - tokio::time::timeout(timeout_duration, sentbox_handle) + for b in once(self.inbox).chain(self.oboxes.into_iter()) { + tokio::time::timeout(timeout_duration, b.handle) .await .ok_or_log(context); } diff --git a/src/scheduler/connectivity.rs b/src/scheduler/connectivity.rs index 6ddc9eede..6ede2074f 100644 --- a/src/scheduler/connectivity.rs +++ b/src/scheduler/connectivity.rs @@ -1,18 +1,18 @@ use core::fmt; -use std::{ops::Deref, sync::Arc}; +use std::{iter::once, ops::Deref, sync::Arc}; use anyhow::{anyhow, Result}; use humansize::{format_size, BINARY}; use tokio::sync::{Mutex, RwLockReadGuard}; use crate::events::EventType; -use crate::imap::scan_folders::get_watched_folder_configs; +use crate::imap::{scan_folders::get_watched_folder_configs, FolderMeaning}; use crate::quota::{ QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_MAX_AGE_SECONDS, QUOTA_WARN_THRESHOLD_PERCENTAGE, }; use crate::tools::time; -use crate::{config::Config, scheduler::Scheduler, stock_str, tools}; use crate::{context::Context, log::LogExt}; +use crate::{scheduler::Scheduler, stock_str, tools}; #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumProperty, PartialOrd, Ord)] pub enum Connectivity { @@ -157,17 +157,14 @@ impl ConnectivityStore { /// Called during `dc_maybe_network()` to make sure that `dc_accounts_all_work_done()` /// returns false immediately after `dc_maybe_network()`. pub(crate) async fn idle_interrupted(scheduler: RwLockReadGuard<'_, Option>) { - let [inbox, mvbox, sentbox] = match &*scheduler { - Some(Scheduler { - inbox, - mvbox, - sentbox, - .. - }) => [ - inbox.state.connectivity.clone(), - mvbox.state.connectivity.clone(), - sentbox.state.connectivity.clone(), - ], + let (inbox, oboxes) = match &*scheduler { + Some(Scheduler { inbox, oboxes, .. }) => ( + inbox.conn_state.state.connectivity.clone(), + oboxes + .iter() + .map(|b| b.conn_state.state.connectivity.clone()) + .collect::>(), + ), None => return, }; drop(scheduler); @@ -185,7 +182,7 @@ pub(crate) async fn idle_interrupted(scheduler: RwLockReadGuard<'_, Option>, ) { - let stores = match &*scheduler { - Some(Scheduler { - inbox, - mvbox, - sentbox, - .. - }) => [ - inbox.state.connectivity.clone(), - mvbox.state.connectivity.clone(), - sentbox.state.connectivity.clone(), - ], + let stores: Vec<_> = match &*scheduler { + Some(sched) => sched + .boxes() + .map(|b| b.conn_state.state.connectivity.clone()) + .collect(), None => return, }; drop(scheduler); @@ -260,14 +251,9 @@ impl Context { pub async fn get_connectivity(&self) -> Connectivity { let lock = self.scheduler.read().await; let stores: Vec<_> = match &*lock { - Some(Scheduler { - inbox, - mvbox, - sentbox, - .. - }) => [&inbox.state, &mvbox.state, &sentbox.state] - .iter() - .map(|state| state.connectivity.clone()) + Some(sched) => sched + .boxes() + .map(|b| b.conn_state.state.connectivity.clone()) .collect(), None => return Connectivity::NotConnected, }; @@ -348,28 +334,12 @@ impl Context { let lock = self.scheduler.read().await; let (folders_states, smtp) = match &*lock { - Some(Scheduler { - inbox, - mvbox, - sentbox, - smtp, - .. - }) => ( - [ - ( - Config::ConfiguredInboxFolder, - inbox.state.connectivity.clone(), - ), - ( - Config::ConfiguredMvboxFolder, - mvbox.state.connectivity.clone(), - ), - ( - Config::ConfiguredSentboxFolder, - sentbox.state.connectivity.clone(), - ), - ], - smtp.state.connectivity.clone(), + Some(sched) => ( + sched + .boxes() + .map(|b| (b.meaning, b.conn_state.state.connectivity.clone())) + .collect::>(), + sched.smtp.state.connectivity.clone(), ), None => { return Err(anyhow!("Not started")); @@ -390,8 +360,8 @@ impl Context { for (folder, state) in &folders_states { let mut folder_added = false; - if watched_folders.contains(folder) { - let f = self.get_config(*folder).await.ok_or_log(self).flatten(); + if let Some(config) = folder.to_config().filter(|c| watched_folders.contains(c)) { + let f = self.get_config(config).await.ok_or_log(self).flatten(); if let Some(foldername) = f { let detailed = &state.get_detailed().await; @@ -407,7 +377,7 @@ impl Context { } } - if !folder_added && folder == &Config::ConfiguredInboxFolder { + if !folder_added && folder == &FolderMeaning::Inbox { let detailed = &state.get_detailed().await; if let DetailedConnectivity::Error(_) = detailed { // On the inbox thread, we also do some other things like scan_folders and run jobs @@ -535,14 +505,10 @@ impl Context { pub async fn all_work_done(&self) -> bool { let lock = self.scheduler.read().await; let stores: Vec<_> = match &*lock { - Some(Scheduler { - inbox, - mvbox, - sentbox, - smtp, - .. - }) => [&inbox.state, &mvbox.state, &sentbox.state, &smtp.state] - .iter() + Some(sched) => sched + .boxes() + .map(|b| &b.conn_state.state) + .chain(once(&sched.smtp.state)) .map(|state| state.connectivity.clone()) .collect(), None => return false,