deltachat/
quota.rs

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