1use 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
14pub const QUOTA_WARN_THRESHOLD_PERCENTAGE: u64 = 80;
16
17pub const QUOTA_ERROR_THRESHOLD_PERCENTAGE: u64 = 95;
19
20#[derive(Debug, thiserror::Error)]
22pub enum Error {
23 #[error("Quota info not supported by the provider")]
25 NotSupportedByProvider,
26
27 #[error("{0:#}")]
29 Other(#[from] anyhow::Error),
30}
31
32#[derive(Debug)]
34pub struct QuotaInfo {
35 pub(crate) recent: Result<BTreeMap<String, Vec<QuotaResource>>, Error>,
39
40 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 for qr_entries in quota_roots {
52 for quota_root_name in &qr_entries.quota_root_names {
53 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 *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 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("a.modified) >= Duration::from_secs(ratelimit_secs)
76 })
77 }
78
79 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}