Skip to main content

rusmes_imap/
condstore.rs

1//! IMAP CONDSTORE Extension - RFC 7162
2//!
3//! This module implements Conditional STORE with MODSEQ tracking,
4//! enabling efficient synchronization of IMAP mailboxes.
5
6use rusmes_storage::ModSeq;
7use std::fmt;
8
9/// CONDSTORE capability enablement state
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CondStoreState {
12    /// CONDSTORE not enabled
13    Disabled,
14    /// CONDSTORE enabled explicitly via ENABLE
15    Enabled,
16    /// CONDSTORE enabled implicitly (via SELECT/FETCH with CONDSTORE params)
17    ImplicitlyEnabled,
18}
19
20impl CondStoreState {
21    /// Check if CONDSTORE is enabled (explicitly or implicitly)
22    pub fn is_enabled(&self) -> bool {
23        matches!(self, Self::Enabled | Self::ImplicitlyEnabled)
24    }
25}
26
27/// CHANGEDSINCE search modifier for FETCH/SEARCH commands
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ChangedSince {
30    /// MODSEQ value to compare against
31    pub modseq: ModSeq,
32}
33
34impl ChangedSince {
35    /// Create new CHANGEDSINCE modifier
36    pub fn new(modseq: ModSeq) -> Self {
37        Self { modseq }
38    }
39
40    /// Parse CHANGEDSINCE from command arguments
41    ///
42    /// Format: CHANGEDSINCE `<modseq>`
43    pub fn parse(args: &str) -> Result<Self, CondStoreError> {
44        let modseq = args
45            .trim()
46            .parse::<u64>()
47            .map_err(|_| CondStoreError::InvalidModSeq(args.to_string()))?;
48
49        if modseq == 0 {
50            return Err(CondStoreError::ZeroModSeq);
51        }
52
53        Ok(Self::new(ModSeq::new(modseq)))
54    }
55
56    /// Check if a message with the given MODSEQ matches this criteria
57    pub fn matches(&self, message_modseq: ModSeq) -> bool {
58        message_modseq > self.modseq
59    }
60}
61
62/// UNCHANGEDSINCE store modifier for conditional STORE
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct UnchangedSince {
65    /// MODSEQ value to compare against
66    pub modseq: ModSeq,
67}
68
69impl UnchangedSince {
70    /// Create new UNCHANGEDSINCE modifier
71    pub fn new(modseq: ModSeq) -> Self {
72        Self { modseq }
73    }
74
75    /// Parse UNCHANGEDSINCE from command arguments
76    ///
77    /// Format: (UNCHANGEDSINCE `<modseq>`)
78    pub fn parse(args: &str) -> Result<Self, CondStoreError> {
79        // Remove parentheses if present
80        let args = args.trim().trim_matches(|c| c == '(' || c == ')');
81
82        // Check for UNCHANGEDSINCE keyword
83        let parts: Vec<&str> = args.split_whitespace().collect();
84        if parts.len() != 2 || !parts[0].eq_ignore_ascii_case("UNCHANGEDSINCE") {
85            return Err(CondStoreError::InvalidUnchangedSince(args.to_string()));
86        }
87
88        let modseq = parts[1]
89            .parse::<u64>()
90            .map_err(|_| CondStoreError::InvalidModSeq(parts[1].to_string()))?;
91
92        if modseq == 0 {
93            return Err(CondStoreError::ZeroModSeq);
94        }
95
96        Ok(Self::new(ModSeq::new(modseq)))
97    }
98
99    /// Check if a message can be modified (MODSEQ hasn't changed)
100    pub fn can_modify(&self, current_modseq: ModSeq) -> bool {
101        current_modseq <= self.modseq
102    }
103}
104
105/// CONDSTORE-related errors
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum CondStoreError {
108    /// Invalid MODSEQ value
109    InvalidModSeq(String),
110    /// MODSEQ cannot be zero
111    ZeroModSeq,
112    /// Invalid UNCHANGEDSINCE syntax
113    InvalidUnchangedSince(String),
114    /// CONDSTORE not enabled for this session
115    NotEnabled,
116    /// STORE failed due to UNCHANGEDSINCE condition
117    StoreFailedModified {
118        /// UIDs that were not modified due to UNCHANGEDSINCE
119        failed_uids: Vec<u32>,
120    },
121}
122
123impl fmt::Display for CondStoreError {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            CondStoreError::InvalidModSeq(s) => write!(f, "Invalid MODSEQ: {}", s),
127            CondStoreError::ZeroModSeq => write!(f, "MODSEQ cannot be zero"),
128            CondStoreError::InvalidUnchangedSince(s) => {
129                write!(f, "Invalid UNCHANGEDSINCE syntax: {}", s)
130            }
131            CondStoreError::NotEnabled => write!(f, "CONDSTORE not enabled"),
132            CondStoreError::StoreFailedModified { failed_uids } => {
133                write!(f, "STORE failed for UIDs (modified): {:?}", failed_uids)
134            }
135        }
136    }
137}
138
139impl std::error::Error for CondStoreError {}
140
141/// CONDSTORE response data for FETCH
142#[derive(Debug, Clone)]
143pub struct CondStoreResponse {
144    /// Message UID
145    pub uid: u32,
146    /// Current MODSEQ value
147    pub modseq: ModSeq,
148    /// Message sequence number
149    pub seq: u32,
150}
151
152impl CondStoreResponse {
153    /// Create new CONDSTORE response
154    pub fn new(uid: u32, modseq: ModSeq, seq: u32) -> Self {
155        Self { uid, modseq, seq }
156    }
157
158    /// Format as IMAP FETCH response
159    ///
160    /// Example: * 1 FETCH (UID 42 MODSEQ (12345))
161    pub fn to_fetch_response(&self) -> String {
162        format!(
163            "* {} FETCH (UID {} MODSEQ ({}))",
164            self.seq, self.uid, self.modseq
165        )
166    }
167}
168
169/// Mailbox status with CONDSTORE support
170#[derive(Debug, Clone)]
171pub struct CondStoreStatus {
172    /// Mailbox name
173    pub mailbox: String,
174    /// Highest MODSEQ in the mailbox
175    pub highestmodseq: ModSeq,
176    /// Number of messages
177    pub exists: u32,
178    /// Number of recent messages
179    pub recent: u32,
180    /// Number of unseen messages
181    pub unseen: u32,
182    /// UIDVALIDITY
183    pub uidvalidity: u32,
184    /// Next UID
185    pub uidnext: u32,
186}
187
188impl CondStoreStatus {
189    /// Format as IMAP STATUS response
190    ///
191    /// Example: * STATUS INBOX (MESSAGES 5 HIGHESTMODSEQ 12345)
192    pub fn to_status_response(&self) -> String {
193        format!(
194            "* STATUS {} (MESSAGES {} RECENT {} UNSEEN {} UIDVALIDITY {} UIDNEXT {} HIGHESTMODSEQ {})",
195            self.mailbox,
196            self.exists,
197            self.recent,
198            self.unseen,
199            self.uidvalidity,
200            self.uidnext,
201            self.highestmodseq
202        )
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_condstore_state() {
212        let state = CondStoreState::Disabled;
213        assert!(!state.is_enabled());
214
215        let state = CondStoreState::Enabled;
216        assert!(state.is_enabled());
217
218        let state = CondStoreState::ImplicitlyEnabled;
219        assert!(state.is_enabled());
220    }
221
222    #[test]
223    fn test_condstore_state_equality() {
224        assert_eq!(CondStoreState::Disabled, CondStoreState::Disabled);
225        assert_eq!(CondStoreState::Enabled, CondStoreState::Enabled);
226        assert_ne!(CondStoreState::Disabled, CondStoreState::Enabled);
227    }
228
229    #[test]
230    fn test_changed_since_parse() {
231        let cs = ChangedSince::parse("12345").expect("valid CHANGEDSINCE value");
232        assert_eq!(cs.modseq.value(), 12345);
233
234        assert!(ChangedSince::parse("0").is_err());
235        assert!(ChangedSince::parse("abc").is_err());
236    }
237
238    #[test]
239    fn test_changed_since_parse_with_whitespace() {
240        let cs =
241            ChangedSince::parse("  12345  ").expect("CHANGEDSINCE with whitespace should parse");
242        assert_eq!(cs.modseq.value(), 12345);
243    }
244
245    #[test]
246    fn test_changed_since_parse_large_value() {
247        let cs = ChangedSince::parse("18446744073709551615")
248            .expect("CHANGEDSINCE with u64::MAX should parse");
249        assert_eq!(cs.modseq.value(), u64::MAX);
250    }
251
252    #[test]
253    fn test_changed_since_parse_invalid_values() {
254        assert!(matches!(
255            ChangedSince::parse("0").unwrap_err(),
256            CondStoreError::ZeroModSeq
257        ));
258        assert!(matches!(
259            ChangedSince::parse("abc").unwrap_err(),
260            CondStoreError::InvalidModSeq(_)
261        ));
262        assert!(matches!(
263            ChangedSince::parse("-1").unwrap_err(),
264            CondStoreError::InvalidModSeq(_)
265        ));
266    }
267
268    #[test]
269    fn test_changed_since_matches() {
270        let cs = ChangedSince::new(ModSeq::new(100));
271
272        assert!(!cs.matches(ModSeq::new(50)));
273        assert!(!cs.matches(ModSeq::new(100)));
274        assert!(cs.matches(ModSeq::new(150)));
275    }
276
277    #[test]
278    fn test_changed_since_matches_edge_cases() {
279        let cs = ChangedSince::new(ModSeq::new(1));
280        assert!(!cs.matches(ModSeq::new(1)));
281        assert!(cs.matches(ModSeq::new(2)));
282
283        let cs = ChangedSince::new(ModSeq::new(u64::MAX - 1));
284        assert!(cs.matches(ModSeq::new(u64::MAX)));
285    }
286
287    #[test]
288    fn test_changed_since_clone() {
289        let cs1 = ChangedSince::new(ModSeq::new(100));
290        let cs2 = cs1;
291        assert_eq!(cs1.modseq, cs2.modseq);
292    }
293
294    #[test]
295    fn test_unchanged_since_parse() {
296        let us = UnchangedSince::parse("(UNCHANGEDSINCE 12345)")
297            .expect("UNCHANGEDSINCE in parens should parse");
298        assert_eq!(us.modseq.value(), 12345);
299
300        let us = UnchangedSince::parse("UNCHANGEDSINCE 12345")
301            .expect("UNCHANGEDSINCE without parens should parse");
302        assert_eq!(us.modseq.value(), 12345);
303
304        assert!(UnchangedSince::parse("INVALID 12345").is_err());
305        assert!(UnchangedSince::parse("UNCHANGEDSINCE 0").is_err());
306    }
307
308    #[test]
309    fn test_unchanged_since_parse_case_insensitive() {
310        let us = UnchangedSince::parse("unchangedsince 12345")
311            .expect("lowercase unchangedsince should parse");
312        assert_eq!(us.modseq.value(), 12345);
313
314        let us = UnchangedSince::parse("UnChAnGeDsInCe 12345")
315            .expect("mixed-case UnChAnGeDsInCe should parse");
316        assert_eq!(us.modseq.value(), 12345);
317    }
318
319    #[test]
320    fn test_unchanged_since_parse_with_multiple_spaces() {
321        let us = UnchangedSince::parse("  UNCHANGEDSINCE   12345  ")
322            .expect("UNCHANGEDSINCE with extra spaces should parse");
323        assert_eq!(us.modseq.value(), 12345);
324    }
325
326    #[test]
327    fn test_unchanged_since_parse_invalid() {
328        assert!(UnchangedSince::parse("CHANGEDSINCE 12345").is_err());
329        assert!(UnchangedSince::parse("12345").is_err());
330        assert!(UnchangedSince::parse("UNCHANGEDSINCE").is_err());
331        assert!(UnchangedSince::parse("UNCHANGEDSINCE abc").is_err());
332    }
333
334    #[test]
335    fn test_unchanged_since_can_modify() {
336        let us = UnchangedSince::new(ModSeq::new(100));
337
338        assert!(us.can_modify(ModSeq::new(50)));
339        assert!(us.can_modify(ModSeq::new(100)));
340        assert!(!us.can_modify(ModSeq::new(150)));
341    }
342
343    #[test]
344    fn test_unchanged_since_can_modify_edge_cases() {
345        let us = UnchangedSince::new(ModSeq::new(1));
346        assert!(us.can_modify(ModSeq::new(1)));
347        assert!(!us.can_modify(ModSeq::new(2)));
348
349        let us = UnchangedSince::new(ModSeq::new(u64::MAX));
350        assert!(us.can_modify(ModSeq::new(u64::MAX)));
351    }
352
353    #[test]
354    fn test_condstore_error_display() {
355        let err = CondStoreError::InvalidModSeq("abc".to_string());
356        assert_eq!(err.to_string(), "Invalid MODSEQ: abc");
357
358        let err = CondStoreError::ZeroModSeq;
359        assert_eq!(err.to_string(), "MODSEQ cannot be zero");
360
361        let err = CondStoreError::NotEnabled;
362        assert_eq!(err.to_string(), "CONDSTORE not enabled");
363    }
364
365    #[test]
366    fn test_condstore_error_store_failed() {
367        let err = CondStoreError::StoreFailedModified {
368            failed_uids: vec![1, 2, 3],
369        };
370        assert!(err.to_string().contains("STORE failed"));
371        assert!(err.to_string().contains("[1, 2, 3]"));
372    }
373
374    #[test]
375    fn test_condstore_response() {
376        let resp = CondStoreResponse::new(42, ModSeq::new(12345), 1);
377        assert_eq!(
378            resp.to_fetch_response(),
379            "* 1 FETCH (UID 42 MODSEQ (12345))"
380        );
381    }
382
383    #[test]
384    fn test_condstore_response_multiple() {
385        let resp1 = CondStoreResponse::new(1, ModSeq::new(100), 1);
386        let resp2 = CondStoreResponse::new(2, ModSeq::new(200), 2);
387        let resp3 = CondStoreResponse::new(3, ModSeq::new(300), 3);
388
389        assert_eq!(resp1.to_fetch_response(), "* 1 FETCH (UID 1 MODSEQ (100))");
390        assert_eq!(resp2.to_fetch_response(), "* 2 FETCH (UID 2 MODSEQ (200))");
391        assert_eq!(resp3.to_fetch_response(), "* 3 FETCH (UID 3 MODSEQ (300))");
392    }
393
394    #[test]
395    fn test_condstore_response_clone() {
396        let resp1 = CondStoreResponse::new(42, ModSeq::new(12345), 1);
397        let resp2 = resp1.clone();
398        assert_eq!(resp1.uid, resp2.uid);
399        assert_eq!(resp1.modseq, resp2.modseq);
400        assert_eq!(resp1.seq, resp2.seq);
401    }
402
403    #[test]
404    fn test_condstore_status() {
405        let status = CondStoreStatus {
406            mailbox: "INBOX".to_string(),
407            highestmodseq: ModSeq::new(12345),
408            exists: 5,
409            recent: 2,
410            unseen: 3,
411            uidvalidity: 1,
412            uidnext: 6,
413        };
414
415        let response = status.to_status_response();
416        assert!(response.contains("INBOX"));
417        assert!(response.contains("HIGHESTMODSEQ 12345"));
418        assert!(response.contains("MESSAGES 5"));
419    }
420
421    #[test]
422    fn test_condstore_status_format() {
423        let status = CondStoreStatus {
424            mailbox: "Sent".to_string(),
425            highestmodseq: ModSeq::new(99999),
426            exists: 100,
427            recent: 5,
428            unseen: 10,
429            uidvalidity: 42,
430            uidnext: 101,
431        };
432
433        let response = status.to_status_response();
434        assert!(response.starts_with("* STATUS Sent"));
435        assert!(response.contains("MESSAGES 100"));
436        assert!(response.contains("RECENT 5"));
437        assert!(response.contains("UNSEEN 10"));
438        assert!(response.contains("UIDVALIDITY 42"));
439        assert!(response.contains("UIDNEXT 101"));
440        assert!(response.contains("HIGHESTMODSEQ 99999"));
441    }
442
443    #[test]
444    fn test_condstore_status_clone() {
445        let status1 = CondStoreStatus {
446            mailbox: "INBOX".to_string(),
447            highestmodseq: ModSeq::new(12345),
448            exists: 5,
449            recent: 2,
450            unseen: 3,
451            uidvalidity: 1,
452            uidnext: 6,
453        };
454        let status2 = status1.clone();
455        assert_eq!(status1.mailbox, status2.mailbox);
456        assert_eq!(status1.highestmodseq, status2.highestmodseq);
457    }
458
459    #[test]
460    fn test_condstore_status_zero_messages() {
461        let status = CondStoreStatus {
462            mailbox: "Empty".to_string(),
463            highestmodseq: ModSeq::new(1),
464            exists: 0,
465            recent: 0,
466            unseen: 0,
467            uidvalidity: 1,
468            uidnext: 1,
469        };
470
471        let response = status.to_status_response();
472        assert!(response.contains("MESSAGES 0"));
473        assert!(response.contains("RECENT 0"));
474        assert!(response.contains("UNSEEN 0"));
475    }
476}