Add Quota to Connectivity View (#2612)

* add imap::get_quota_roots()

* schedule quote-checking job on getting connectivity-html

* get quota and debug print it

* basic quota output

* update quota at most once per minute, emit event on changes

* use more meaningful names

* add some comments, move update_recent_quota() to quota.rs

* show root name only if there are several roots

* make clippy happy, some refactorings

* allow only one update-quota job per time

* add now supported QUOTA to standards.md
This commit is contained in:
bjoern
2021-08-20 10:40:24 +02:00
committed by GitHub
parent 53cd633e8d
commit 5399c9151d
10 changed files with 283 additions and 11 deletions

View File

@@ -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<Scheduler>,
pub(crate) ephemeral_task: RwLock<Option<task::JoinHandle<()>>>,
/// Recently loaded quota information, if any.
/// Set to `None` if quota was never tried to load.
pub(crate) quota: RwLock<Option<QuotaInfo>>,
pub(crate) last_full_folder_scan: Mutex<Option<Instant>>,
/// 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),
};

View File

@@ -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
/// <https://tools.ietf.org/html/rfc6851>
pub can_move: bool,
/// True if the server has QUOTA capability as defined in
/// <https://tools.ietf.org/html/rfc2087>
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<QuotaRoot>, Vec<Quota>)> {
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.

View File

@@ -102,7 +102,7 @@ impl Imap {
}
}
async fn get_watched_folders(context: &Context) -> Vec<String> {
pub(crate) async fn get_watched_folders(context: &Context) -> Vec<String> {
let mut res = Vec::new();
let folder_watched_configured = &[
(Config::SentboxWatch, Config::ConfiguredSentboxFolder),

View File

@@ -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<Action> 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))

View File

@@ -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;

100
src/quota.rs Normal file
View File

@@ -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<IndexMap<String, Vec<QuotaResource>>>,
/// Timestamp when structure was modified.
pub(crate) modified: i64,
}
async fn get_unique_quota_roots_and_usage(
folders: Vec<String>,
imap: &mut Imap,
) -> Result<IndexMap<String, Vec<QuotaResource>>> {
let mut unique_quota_roots: IndexMap<String, Vec<QuotaResource>> = 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(()))
}
}

View File

@@ -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 += "</li></ul>";
ret += "<h3>Quota</h3><ul>";
let quota = self.quota.read().await;
if let Some(quota) = &*quota {
match &quota.recent {
Ok(quota) => {
let roots_cnt = quota.len();
for (root_name, resources) in quota {
use async_imap::types::QuotaResourceName::*;
for resource in resources {
ret += "<li>";
let usage_percent = resource.get_usage_percentage();
if usage_percent >= QUOTA_ERROR_THRESHOLD_PERCENTAGE {
ret += "<span class=\"red dot\"></span> ";
} else if usage_percent >= QUOTA_WARN_THRESHOLD_PERCENTAGE {
ret += "<span class=\"yellow dot\"></span> ";
} else {
ret += "<span class=\"green dot\"></span> ";
}
// 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!("<b>{}:</b> ", &*escaper::encode_minimal(root_name));
} else {
info!(self, "connectivity: root name hidden: \"{}\"", root_name);
}
ret += &match &resource.name {
Atom(resource_name) => {
format!(
"<b>{}:</b> {} of {} used",
&*escaper::encode_minimal(resource_name),
resource.usage.to_string(),
resource.limit.to_string(),
)
}
Message => {
format!(
"<b>Messages:</b> {} 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!("<b>Storage:</b> {} of {} used", usage, limit)
}
};
ret += &format!(" ({}%)", usage_percent);
ret += "</li>";
}
}
}
Err(e) => {
ret += format!("<li>{}</li>", e).as_str();
}
}
if quota.modified + QUOTA_MAX_AGE_SECONDS < time() {
self.schedule_quota_update().await;
}
} else {
ret += "<li>One moment...</li>";
self.schedule_quota_update().await;
}
ret += "</ul>";
ret += "</body></html>\n";
ret
}