Skip to main content

deltachat/
quota.rs

1//! # Support for IMAP QUOTA extension.
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use anyhow::{Context as _, Result};
7use async_imap::types::{Quota, QuotaResource};
8
9use crate::EventType;
10use crate::context::Context;
11use crate::imap::session::Session as ImapSession;
12use crate::tools::{self, time_elapsed};
13
14/// quota icon in connectivity is "yellow".
15pub const QUOTA_WARN_THRESHOLD_PERCENTAGE: u64 = 80;
16
17/// quota icon in connectivity is "red".
18pub const QUOTA_ERROR_THRESHOLD_PERCENTAGE: u64 = 95;
19
20/// [QuotaInfo] error.
21#[derive(Debug, thiserror::Error)]
22pub enum Error {
23    /// Quota info not supported by the provider
24    #[error("Quota info not supported by the provider")]
25    NotSupportedByProvider,
26
27    /// Any other error: network, parsing, etc.
28    #[error("{0:#}")]
29    Other(#[from] anyhow::Error),
30}
31
32/// Server quota information with an update timestamp.
33#[derive(Debug)]
34pub struct QuotaInfo {
35    /// Recently loaded quota information.
36    /// set to `Err()` if the provider does not support quota or on other errors,
37    /// set to `Ok()` for valid quota information.
38    pub(crate) recent: Result<BTreeMap<String, Vec<QuotaResource>>, Error>,
39
40    /// When the structure was modified.
41    pub(crate) modified: tools::Time,
42}
43
44async fn get_unique_quota_roots_and_usage(
45    session: &mut ImapSession,
46    folder: &str,
47) -> Result<BTreeMap<String, Vec<QuotaResource>>> {
48    let mut unique_quota_roots: BTreeMap<String, Vec<QuotaResource>> = BTreeMap::new();
49    let (quota_roots, quotas) = &session.get_quota_root(folder).await?;
50    // if there are new quota roots found in this imap folder, add them to the list
51    for qr_entries in quota_roots {
52        for quota_root_name in &qr_entries.quota_root_names {
53            // the quota for that quota root
54            let quota: Quota = quotas
55                .iter()
56                .find(|q| &q.root_name == quota_root_name)
57                .cloned()
58                .context("quota_root should have a quota")?;
59            // replace old quotas, because between fetching quotaroots for folders,
60            // messages could be received and so the usage could have been changed
61            *unique_quota_roots
62                .entry(quota_root_name.clone())
63                .or_default() = quota.resources;
64        }
65    }
66    Ok(unique_quota_roots)
67}
68
69impl Context {
70    /// Returns whether the quota value needs an update. If so, `update_recent_quota()` should be
71    /// called.
72    pub(crate) async fn quota_needs_update(&self, transport_id: u32, ratelimit_secs: u64) -> bool {
73        let quota = self.quota.read().await;
74        quota.get(&transport_id).is_none_or(|quota| {
75            time_elapsed(&quota.modified) >= Duration::from_secs(ratelimit_secs)
76        })
77    }
78
79    /// Updates `quota.recent`, sets `quota.modified` to the current time
80    /// and emits an event to let the UIs update connectivity view.
81    pub(crate) async fn update_recent_quota(
82        &self,
83        session: &mut ImapSession,
84        folder: &str,
85    ) -> Result<()> {
86        let transport_id = session.transport_id();
87
88        info!(self, "Transport {transport_id}: Updating quota.");
89
90        let quota = if session.can_check_quota() {
91            get_unique_quota_roots_and_usage(session, folder)
92                .await
93                .map_err(Error::Other)
94        } else {
95            Err(Error::NotSupportedByProvider)
96        };
97
98        self.quota.write().await.insert(
99            transport_id,
100            QuotaInfo {
101                recent: quota,
102                modified: tools::Time::now(),
103            },
104        );
105
106        info!(self, "Transport {transport_id}: Updated quota.");
107        self.emit_event(EventType::ConnectivityChanged);
108        Ok(())
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::test_utils::TestContextManager;
116
117    #[expect(clippy::assertions_on_constants)]
118    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
119    async fn test_quota_thresholds() -> anyhow::Result<()> {
120        assert!(0 < QUOTA_WARN_THRESHOLD_PERCENTAGE);
121        assert!(QUOTA_WARN_THRESHOLD_PERCENTAGE < QUOTA_ERROR_THRESHOLD_PERCENTAGE);
122        assert!(QUOTA_ERROR_THRESHOLD_PERCENTAGE < 100);
123        Ok(())
124    }
125
126    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
127    async fn test_quota_needs_update() -> Result<()> {
128        let mut tcm = TestContextManager::new();
129        let t = &tcm.unconfigured().await;
130        const TIMEOUT: u64 = 60;
131        assert!(t.quota_needs_update(0, TIMEOUT).await);
132
133        *t.quota.write().await = {
134            let mut map = BTreeMap::new();
135            map.insert(
136                0,
137                QuotaInfo {
138                    recent: Ok(Default::default()),
139                    modified: tools::Time::now() - Duration::from_secs(TIMEOUT + 1),
140                },
141            );
142            map
143        };
144        assert!(t.quota_needs_update(0, TIMEOUT).await);
145
146        *t.quota.write().await = {
147            let mut map = BTreeMap::new();
148            map.insert(
149                0,
150                QuotaInfo {
151                    recent: Ok(Default::default()),
152                    modified: tools::Time::now(),
153                },
154            );
155            map
156        };
157        assert!(!t.quota_needs_update(0, TIMEOUT).await);
158
159        t.evtracker.clear_events();
160        t.set_primary_self_addr("new@addr").await?;
161        assert!(t.quota.read().await.is_empty());
162        t.evtracker
163            .get_matching(|evt| matches!(evt, EventType::ConnectivityChanged))
164            .await;
165        assert!(t.quota_needs_update(0, TIMEOUT).await);
166
167        Ok(())
168    }
169}