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