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