deltachat/
quota.rs

1//! # Support for IMAP QUOTA extension.
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use anyhow::{anyhow, Context as _, Result};
7use async_imap::types::{Quota, QuotaResource};
8
9use crate::chat::add_device_msg_with_importance;
10use crate::config::Config;
11use crate::context::Context;
12use crate::imap::scan_folders::get_watched_folders;
13use crate::imap::session::Session as ImapSession;
14use crate::message::Message;
15use crate::tools::{self, time_elapsed};
16use crate::{stock_str, EventType};
17
18/// warn about a nearly full mailbox after this usage percentage is reached.
19/// quota icon is "yellow".
20pub const QUOTA_WARN_THRESHOLD_PERCENTAGE: u64 = 80;
21
22/// warning again after this usage percentage is reached,
23/// quota icon is "red".
24pub const QUOTA_ERROR_THRESHOLD_PERCENTAGE: u64 = 95;
25
26/// if quota is below this value (again),
27/// QuotaExceeding is cleared.
28///
29/// This value should be a bit below QUOTA_WARN_THRESHOLD_PERCENTAGE to
30/// avoid jittering and lots of warnings when quota is exactly at the warning threshold.
31///
32/// We do not repeat warnings on a daily base or so as some provider
33/// providers report bad values and we would then spam the user.
34pub const QUOTA_ALLCLEAR_PERCENTAGE: u64 = 75;
35
36/// Server quota information with an update timestamp.
37#[derive(Debug)]
38pub struct QuotaInfo {
39    /// Recently loaded quota information.
40    /// set to `Err()` if the provider does not support quota or on other errors,
41    /// set to `Ok()` for valid quota information.
42    pub(crate) recent: Result<BTreeMap<String, Vec<QuotaResource>>>,
43
44    /// When the structure was modified.
45    pub(crate) modified: tools::Time,
46}
47
48async fn get_unique_quota_roots_and_usage(
49    session: &mut ImapSession,
50    folders: Vec<String>,
51) -> Result<BTreeMap<String, Vec<QuotaResource>>> {
52    let mut unique_quota_roots: BTreeMap<String, Vec<QuotaResource>> = BTreeMap::new();
53    for folder in folders {
54        let (quota_roots, quotas) = &session.get_quota_root(&folder).await?;
55        // if there are new quota roots found in this imap folder, add them to the list
56        for qr_entries in quota_roots {
57            for quota_root_name in &qr_entries.quota_root_names {
58                // the quota for that quota root
59                let quota: Quota = quotas
60                    .iter()
61                    .find(|q| &q.root_name == quota_root_name)
62                    .cloned()
63                    .context("quota_root should have a quota")?;
64                // replace old quotas, because between fetching quotaroots for folders,
65                // messages could be received and so the usage could have been changed
66                *unique_quota_roots
67                    .entry(quota_root_name.clone())
68                    .or_default() = quota.resources;
69            }
70        }
71    }
72    Ok(unique_quota_roots)
73}
74
75fn get_highest_usage<'t>(
76    unique_quota_roots: &'t BTreeMap<String, Vec<QuotaResource>>,
77) -> Result<(u64, &'t String, &'t QuotaResource)> {
78    let mut highest: Option<(u64, &'t String, &QuotaResource)> = None;
79    for (name, resources) in unique_quota_roots {
80        for r in resources {
81            let usage_percent = r.get_usage_percentage();
82            match highest {
83                None => {
84                    highest = Some((usage_percent, name, r));
85                }
86                Some((up, ..)) => {
87                    if up <= usage_percent {
88                        highest = Some((usage_percent, name, r));
89                    }
90                }
91            };
92        }
93    }
94
95    highest.context("no quota_resource found, this is unexpected")
96}
97
98/// Checks if a quota warning is needed.
99pub fn needs_quota_warning(curr_percentage: u64, warned_at_percentage: u64) -> bool {
100    (curr_percentage >= QUOTA_WARN_THRESHOLD_PERCENTAGE
101        && warned_at_percentage < QUOTA_WARN_THRESHOLD_PERCENTAGE)
102        || (curr_percentage >= QUOTA_ERROR_THRESHOLD_PERCENTAGE
103            && warned_at_percentage < QUOTA_ERROR_THRESHOLD_PERCENTAGE)
104}
105
106impl Context {
107    /// Returns whether the quota value needs an update. If so, `update_recent_quota()` should be
108    /// called.
109    pub(crate) async fn quota_needs_update(&self, ratelimit_secs: u64) -> bool {
110        let quota = self.quota.read().await;
111        quota
112            .as_ref()
113            .filter(|quota| time_elapsed(&quota.modified) < Duration::from_secs(ratelimit_secs))
114            .is_none()
115    }
116
117    /// Updates `quota.recent`, sets `quota.modified` to the current time
118    /// and emits an event to let the UIs update connectivity view.
119    ///
120    /// Moreover, once each time quota gets larger than `QUOTA_WARN_THRESHOLD_PERCENTAGE`,
121    /// a device message is added.
122    /// As the message is added only once, the user is not spammed
123    /// in case for some providers the quota is always at ~100%
124    /// and new space is allocated as needed.
125    pub(crate) async fn update_recent_quota(&self, session: &mut ImapSession) -> Result<()> {
126        let quota = if session.can_check_quota() {
127            let folders = get_watched_folders(self).await?;
128            get_unique_quota_roots_and_usage(session, folders).await
129        } else {
130            Err(anyhow!(stock_str::not_supported_by_provider(self).await))
131        };
132
133        if let Ok(quota) = &quota {
134            match get_highest_usage(quota) {
135                Ok((highest, _, _)) => {
136                    if needs_quota_warning(
137                        highest,
138                        self.get_config_int(Config::QuotaExceeding).await? as u64,
139                    ) {
140                        self.set_config_internal(
141                            Config::QuotaExceeding,
142                            Some(&highest.to_string()),
143                        )
144                        .await?;
145                        let mut msg =
146                            Message::new_text(stock_str::quota_exceeding(self, highest).await);
147                        add_device_msg_with_importance(self, None, Some(&mut msg), true).await?;
148                    } else if highest <= QUOTA_ALLCLEAR_PERCENTAGE {
149                        self.set_config_internal(Config::QuotaExceeding, None)
150                            .await?;
151                    }
152                }
153                Err(err) => warn!(self, "cannot get highest quota usage: {:#}", err),
154            }
155        }
156
157        *self.quota.write().await = Some(QuotaInfo {
158            recent: quota,
159            modified: tools::Time::now(),
160        });
161
162        self.emit_event(EventType::ConnectivityChanged);
163        Ok(())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::test_utils::TestContextManager;
171
172    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
173    async fn test_needs_quota_warning() -> Result<()> {
174        assert!(!needs_quota_warning(0, 0));
175        assert!(!needs_quota_warning(10, 0));
176        assert!(!needs_quota_warning(70, 0));
177        assert!(!needs_quota_warning(75, 0));
178        assert!(!needs_quota_warning(79, 0));
179        assert!(needs_quota_warning(80, 0));
180        assert!(needs_quota_warning(81, 0));
181        assert!(!needs_quota_warning(85, 80));
182        assert!(!needs_quota_warning(85, 81));
183        assert!(needs_quota_warning(95, 82));
184        assert!(!needs_quota_warning(97, 95));
185        assert!(!needs_quota_warning(97, 96));
186        assert!(!needs_quota_warning(1000, 96));
187        Ok(())
188    }
189
190    #[expect(clippy::assertions_on_constants)]
191    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
192    async fn test_quota_thresholds() -> anyhow::Result<()> {
193        assert!(QUOTA_ALLCLEAR_PERCENTAGE > 50);
194        assert!(QUOTA_ALLCLEAR_PERCENTAGE < QUOTA_WARN_THRESHOLD_PERCENTAGE);
195        assert!(QUOTA_WARN_THRESHOLD_PERCENTAGE < QUOTA_ERROR_THRESHOLD_PERCENTAGE);
196        assert!(QUOTA_ERROR_THRESHOLD_PERCENTAGE < 100);
197        Ok(())
198    }
199
200    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
201    async fn test_quota_needs_update() -> Result<()> {
202        let mut tcm = TestContextManager::new();
203        let t = &tcm.unconfigured().await;
204        const TIMEOUT: u64 = 60;
205        assert!(t.quota_needs_update(TIMEOUT).await);
206
207        *t.quota.write().await = Some(QuotaInfo {
208            recent: Ok(Default::default()),
209            modified: tools::Time::now() - Duration::from_secs(TIMEOUT + 1),
210        });
211        assert!(t.quota_needs_update(TIMEOUT).await);
212
213        *t.quota.write().await = Some(QuotaInfo {
214            recent: Ok(Default::default()),
215            modified: tools::Time::now(),
216        });
217        assert!(!t.quota_needs_update(TIMEOUT).await);
218
219        t.evtracker.clear_events();
220        t.set_primary_self_addr("new@addr").await?;
221        assert!(t.quota.read().await.is_none());
222        t.evtracker
223            .get_matching(|evt| matches!(evt, EventType::ConnectivityChanged))
224            .await;
225        assert!(t.quota_needs_update(TIMEOUT).await);
226        Ok(())
227    }
228}