diff --git a/Cargo.lock b/Cargo.lock index ba50aac86..69387114b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,8 +238,7 @@ dependencies = [ [[package]] name = "async-imap" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb2df4b37a99456360a9ab475b723e3a499d51e060ab1bdd8d7565d23dcb74b" +source = "git+https://github.com/async-email/async-imap#4ce7da455618c387b87b2905a80935107bc69afc" dependencies = [ "async-native-tls", "async-std", @@ -250,7 +249,7 @@ dependencies = [ "imap-proto", "lazy_static", "log", - "nom 5.1.2", + "nom 6.2.1", "pin-utils", "rental", "stop-token", @@ -522,6 +521,18 @@ version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da1976d75adbe5fbc88130ecd119529cf1cc6a93ae1546d8696ee66f0d21af1" +[[package]] +name = "bitvec" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -1134,6 +1145,7 @@ dependencies = [ "futures", "futures-lite", "hex", + "humansize", "image", "indexmap", "itertools 0.10.1", @@ -1586,6 +1598,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + [[package]] name = "futures" version = "0.3.16" @@ -1916,6 +1934,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "humansize" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" + [[package]] name = "humantime" version = "1.3.0" @@ -1961,11 +1985,11 @@ dependencies = [ [[package]] name = "imap-proto" -version = "0.11.0" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3091b99ee5b80f9b010eb6f962af9495ad06561bf662126b077e8ca30e463182" +checksum = "3ad9b46a79efb6078e578ae04e51463d7c3e8767864687f7e63095b3cbefafbb" dependencies = [ - "nom 5.1.2", + "nom 6.2.1", ] [[package]] @@ -2343,6 +2367,19 @@ dependencies = [ "version_check 0.9.3", ] +[[package]] +name = "nom" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5c51b9083a3c620fa67a2a635d1ce7d95b897e957d6b28ff9a5da960a103a6" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check 0.9.3", +] + [[package]] name = "num-bigint" version = "0.2.6" @@ -2874,6 +2911,12 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "radix_trie" version = "0.2.1" @@ -3651,6 +3694,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.2.0" @@ -4166,6 +4215,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + [[package]] name = "x25519-dalek" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index ce9ed0f1e..4dab79d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ deltachat_derive = { path = "./deltachat_derive" } ansi_term = { version = "0.12.1", optional = true } anyhow = "1.0.42" -async-imap = "0.5.0" +async-imap = { git = "https://github.com/async-email/async-imap" } async-native-tls = { version = "0.3.3" } async-smtp = { git = "https://github.com/async-email/async-smtp", rev="c8800625f7cf29f437143ac7e720ac2730a0962f" } async-std-resolver = "0.20.3" @@ -72,6 +72,7 @@ thiserror = "1.0.26" toml = "0.5.6" url = "2.2.2" uuid = { version = "0.8", features = ["serde", "v4"] } +humansize = "1.1.1" [dev-dependencies] ansi_term = "0.12.0" diff --git a/src/context.rs b/src/context.rs index 1aec6dbc5..e5658fcd4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -22,6 +22,7 @@ use crate::events::{Event, EventEmitter, EventType, Events}; use crate::key::{DcKey, SignedPublicKey}; use crate::login_param::LoginParam; use crate::message::{self, MessageState, MsgId}; +use crate::quota::QuotaInfo; use crate::scheduler::Scheduler; use crate::securejoin::Bob; use crate::sql::Sql; @@ -62,6 +63,10 @@ pub struct InnerContext { pub(crate) scheduler: RwLock, pub(crate) ephemeral_task: RwLock>>, + /// Recently loaded quota information, if any. + /// Set to `None` if quota was never tried to load. + pub(crate) quota: RwLock>, + pub(crate) last_full_folder_scan: Mutex>, /// ID for this `Context` in the current process. @@ -139,6 +144,7 @@ impl Context { events: Events::default(), scheduler: RwLock::new(Scheduler::Stopped), ephemeral_task: RwLock::new(None), + quota: RwLock::new(None), creation_time: std::time::SystemTime::now(), last_full_folder_scan: Mutex::new(None), }; diff --git a/src/imap.rs b/src/imap.rs index 3670a11b6..6a2298a96 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -5,10 +5,10 @@ use std::{cmp, cmp::max, collections::BTreeMap}; -use anyhow::{bail, format_err, Context as _, Result}; +use anyhow::{anyhow, bail, format_err, Context as _, Result}; use async_imap::{ error::Result as ImapResult, - types::{Fetch, Flag, Mailbox, Name, NameAttribute, UnsolicitedResponse}, + types::{Fetch, Flag, Mailbox, Name, NameAttribute, Quota, QuotaRoot, UnsolicitedResponse}, }; use async_std::channel::Receiver; use async_std::prelude::*; @@ -153,6 +153,10 @@ struct ImapConfig { /// True if the server has MOVE capability as defined in /// pub can_move: bool, + + /// True if the server has QUOTA capability as defined in + /// + pub can_check_quota: bool, } impl Imap { @@ -186,6 +190,7 @@ impl Imap { selected_folder_needs_expunge: false, can_idle: false, can_move: false, + can_check_quota: false, }; let imap = Imap { @@ -362,6 +367,7 @@ impl Imap { Ok(caps) => { self.config.can_idle = caps.has_str("IDLE"); self.config.can_move = caps.has_str("MOVE"); + self.config.can_check_quota = caps.has_str("QUOTA"); self.capabilities_determined = true; Ok(()) } @@ -1392,6 +1398,22 @@ impl Imap { } unsolicited_exists } + + pub fn can_check_quota(&self) -> bool { + self.config.can_check_quota + } + + pub async fn get_quota_roots( + &mut self, + mailbox_name: &str, + ) -> Result<(Vec, Vec)> { + if let Some(session) = self.session.as_mut() { + let quota_roots = session.get_quota_root(mailbox_name).await?; + Ok(quota_roots) + } else { + Err(anyhow!("Not connected to IMAP, no session")) + } + } } /// Try to get the folder meaning by the name of the folder only used if the server does not support XLIST. diff --git a/src/imap/scan_folders.rs b/src/imap/scan_folders.rs index af6bfb9d5..5c2df7cd9 100644 --- a/src/imap/scan_folders.rs +++ b/src/imap/scan_folders.rs @@ -102,7 +102,7 @@ impl Imap { } } -async fn get_watched_folders(context: &Context) -> Vec { +pub(crate) async fn get_watched_folders(context: &Context) -> Vec { let mut res = Vec::new(); let folder_watched_configured = &[ (Config::SentboxWatch, Config::ConfiguredSentboxFolder), diff --git a/src/job.rs b/src/job.rs index db6b4f819..ba639593b 100644 --- a/src/job.rs +++ b/src/job.rs @@ -95,6 +95,9 @@ pub enum Action { FetchExistingMsgs = 110, MarkseenMsgOnImap = 130, + // this is user initiated so it should have a fairly high priority + UpdateRecentQuota = 140, + // Moving message is prioritized lower than deletion so we don't // bother moving message if it is already scheduled for deletion. MoveMsg = 200, @@ -130,6 +133,7 @@ impl From for Thread { ResyncFolders => Thread::Imap, MarkseenMsgOnImap => Thread::Imap, MoveMsg => Thread::Imap, + UpdateRecentQuota => Thread::Imap, MaybeSendLocations => Thread::Smtp, MaybeSendLocationsEnded => Thread::Smtp, @@ -1148,6 +1152,7 @@ async fn perform_job_action( sql::housekeeping(context).await.ok_or_log(context); Status::Finished(Ok(())) } + Action::UpdateRecentQuota => context.update_recent_quota(connection.inbox()).await, }; info!(context, "Finished immediate try {} of job {}", tries, job); @@ -1210,7 +1215,8 @@ pub async fn add(context: &Context, job: Job) { | Action::ResyncFolders | Action::MarkseenMsgOnImap | Action::FetchExistingMsgs - | Action::MoveMsg => { + | Action::MoveMsg + | Action::UpdateRecentQuota => { info!(context, "interrupt: imap"); context .interrupt_inbox(InterruptInfo::new(false, None)) diff --git a/src/lib.rs b/src/lib.rs index 5fdbf66fe..e90ddd519 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,6 +75,7 @@ pub mod peerstate; pub mod pgp; pub mod provider; pub mod qr; +pub mod quota; pub mod securejoin; mod simplify; mod smtp; diff --git a/src/quota.rs b/src/quota.rs new file mode 100644 index 000000000..c9a49d601 --- /dev/null +++ b/src/quota.rs @@ -0,0 +1,100 @@ +use anyhow::{anyhow, Result}; +use async_imap::types::{Quota, QuotaResource}; +use indexmap::IndexMap; + +use crate::context::Context; +use crate::dc_tools::time; +use crate::imap::scan_folders::get_watched_folders; +use crate::imap::Imap; +use crate::job::{Action, Status}; +use crate::param::Params; +use crate::{job, EventType}; + +/// warn about a nearly full mailbox after this usage percentage is reached. +/// quota icon is "yellow". +pub const QUOTA_WARN_THRESHOLD_PERCENTAGE: u64 = 80; + +// warning is already issued at QUOTA_WARN_THRESHOLD_PERCENTAGE, +// this threshold only makes the quota icon "red". +pub const QUOTA_ERROR_THRESHOLD_PERCENTAGE: u64 = 99; + +// if recent quota is older, +// it is re-fetched on dc_get_connectivity_html() +pub const QUOTA_MAX_AGE_SECONDS: i64 = 60; + +#[derive(Debug)] +pub struct QuotaInfo { + /// Recently loaded quota information. + /// set to `Err()` if the provider does not support quota or on other errors, + /// set to `Ok()` for valid quota information. + /// Updated by `Action::UpdateRecentQuota` + pub(crate) recent: Result>>, + + /// Timestamp when structure was modified. + pub(crate) modified: i64, +} + +async fn get_unique_quota_roots_and_usage( + folders: Vec, + imap: &mut Imap, +) -> Result>> { + let mut unique_quota_roots: IndexMap> = IndexMap::new(); + for folder in folders { + let (quota_roots, quotas) = &imap.get_quota_roots(&folder).await?; + // if there are new quota roots found in this imap folder, add them to the list + for qr_entries in quota_roots { + for quota_root_name in &qr_entries.quota_root_names { + // the quota for that quota root + let quota: Quota = quotas + .iter() + .find(|q| &q.root_name == quota_root_name) + .cloned() + .ok_or_else(|| anyhow!("quota_root should have a quota"))?; + // replace old quotas, because between fetching quotaroots for folders, + // messages could be recieved and so the usage could have been changed + *unique_quota_roots + .entry(quota_root_name.clone()) + .or_insert(vec![]) = quota.resources; + } + } + } + Ok(unique_quota_roots) +} + +impl Context { + // Adds a job to update `quota.recent` + pub(crate) async fn schedule_quota_update(&self) { + job::kill_action(self, Action::UpdateRecentQuota).await; + job::add( + self, + job::Job::new(Action::UpdateRecentQuota, 0, Params::new(), 0), + ) + .await; + } + + /// Updates `quota.recent`, sets `quota.modified` to the current time + /// and emits an event to let the UIs update connectivity view. + /// + /// Called in response to `Action::UpdateRecentQuota`. + pub(crate) async fn update_recent_quota(&self, imap: &mut Imap) -> Status { + if let Err(err) = imap.prepare(self).await { + warn!(self, "could not connect: {:?}", err); + return Status::RetryNow; + } + + let quota = if imap.can_check_quota() { + let folders = get_watched_folders(self).await; + get_unique_quota_roots_and_usage(folders, imap).await + } else { + Err(anyhow!("Quota not supported by your provider.")) + }; + + *self.quota.write().await = Some(QuotaInfo { + recent: quota, + modified: time(), + }); + + self.emit_event(EventType::ConnectivityChanged); + Status::Finished(Ok(())) + } +} diff --git a/src/scheduler/connectivity.rs b/src/scheduler/connectivity.rs index 0f919e03f..6f9b55c12 100644 --- a/src/scheduler/connectivity.rs +++ b/src/scheduler/connectivity.rs @@ -3,9 +3,14 @@ use std::{ops::Deref, sync::Arc}; use async_std::sync::{Mutex, RwLockReadGuard}; +use crate::dc_tools::time; use crate::events::EventType; +use crate::quota::{ + QUOTA_ERROR_THRESHOLD_PERCENTAGE, QUOTA_MAX_AGE_SECONDS, QUOTA_WARN_THRESHOLD_PERCENTAGE, +}; use crate::{config::Config, scheduler::Scheduler}; use crate::{context::Context, log::LogExt}; +use humansize::{file_size_opts, FileSize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumProperty, PartialOrd, Ord)] pub enum Connectivity { @@ -403,6 +408,81 @@ impl Context { ret += &*escaper::encode_minimal(&detailed.to_string_smtp(self)); ret += ""; + ret += "

Quota

    "; + let quota = self.quota.read().await; + if let Some(quota) = &*quota { + match "a.recent { + Ok(quota) => { + let roots_cnt = quota.len(); + for (root_name, resources) in quota { + use async_imap::types::QuotaResourceName::*; + for resource in resources { + ret += "
  • "; + + let usage_percent = resource.get_usage_percentage(); + if usage_percent >= QUOTA_ERROR_THRESHOLD_PERCENTAGE { + ret += " "; + } else if usage_percent >= QUOTA_WARN_THRESHOLD_PERCENTAGE { + ret += " "; + } else { + ret += " "; + } + + // root name is empty eg. for gmail and redundant eg. for riseup. + // therefore, use it only if there are really several roots. + if roots_cnt > 1 && !root_name.is_empty() { + ret += + &format!("{}: ", &*escaper::encode_minimal(root_name)); + } else { + info!(self, "connectivity: root name hidden: \"{}\"", root_name); + } + + ret += &match &resource.name { + Atom(resource_name) => { + format!( + "{}: {} of {} used", + &*escaper::encode_minimal(resource_name), + resource.usage.to_string(), + resource.limit.to_string(), + ) + } + Message => { + format!( + "Messages: {} of {} used", + resource.usage.to_string(), + resource.limit.to_string(), + ) + } + Storage => { + let usage = (resource.usage * 1024) + .file_size(file_size_opts::BINARY) + .unwrap_or_default(); + let limit = (resource.limit * 1024) + .file_size(file_size_opts::BINARY) + .unwrap_or_default(); + format!("Storage: {} of {} used", usage, limit) + } + }; + ret += &format!(" ({}%)", usage_percent); + + ret += "
  • "; + } + } + } + Err(e) => { + ret += format!("
  • {}
  • ", e).as_str(); + } + } + + if quota.modified + QUOTA_MAX_AGE_SECONDS < time() { + self.schedule_quota_update().await; + } + } else { + ret += "
  • One moment...
  • "; + self.schedule_quota_update().await; + } + ret += "
"; + ret += "\n"; ret } diff --git a/standards.md b/standards.md index fc20bc458..749aba997 100644 --- a/standards.md +++ b/standards.md @@ -10,6 +10,7 @@ Text and Quote encoding | Fixed, Flowed ([RFC 3676](https://tools.ietf. Filename encoding | Encoded Words ([RFC 2047](https://tools.ietf.org/html/rfc2047)), Encoded Word Extensions ([RFC 2231](https://tools.ietf.org/html/rfc2231)) Identify server folders | IMAP LIST Extension ([RFC 6154](https://tools.ietf.org/html/rfc6154)) Push | IMAP IDLE ([RFC 2177](https://tools.ietf.org/html/rfc2177)) +Quota | IMAP QUOTA extension ([RFC 2087](https://tools.ietf.org/html/rfc2087)) Authorization | OAuth2 ([RFC 6749](https://tools.ietf.org/html/rfc6749)) End-to-end encryption | [Autocrypt Level 1](https://autocrypt.org/level1.html), OpenPGP ([RFC 4880](https://tools.ietf.org/html/rfc4880)), Security Multiparts for MIME ([RFC 1847](https://tools.ietf.org/html/rfc1847)) and [“Mixed Up” Encryption repairing](https://tools.ietf.org/id/draft-dkg-openpgp-pgpmime-message-mangling-00.html) Configuration assistance | [Autoconfigure](https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration) and [Autodiscover](https://technet.microsoft.com/library/bb124251(v=exchg.150).aspx)