Skip to main content

rusmes_cli/commands/
mailbox.rs

1//! Mailbox management commands
2
3use anyhow::Result;
4use colored::*;
5use rusmes_storage::StorageBackend;
6use serde::{Deserialize, Serialize};
7use tabled::{Table, Tabled};
8
9use crate::client::Client;
10
11// `walkdir` is used in the offline repair scan.
12use walkdir;
13
14#[derive(Debug, Serialize, Deserialize, Tabled)]
15pub struct MailboxInfo {
16    pub name: String,
17    pub messages: u32,
18    pub unseen: u32,
19    pub size_mb: u64,
20    pub subscribed: bool,
21}
22
23/// List mailboxes for a user
24pub async fn list(client: &Client, user: &str, json: bool) -> Result<()> {
25    let mailboxes: Vec<MailboxInfo> = client
26        .get(&format!("/api/users/{}/mailboxes", user))
27        .await?;
28
29    if json {
30        println!("{}", serde_json::to_string_pretty(&mailboxes)?);
31    } else {
32        if mailboxes.is_empty() {
33            println!("{}", "No mailboxes found".yellow());
34            return Ok(());
35        }
36
37        let table = Table::new(&mailboxes).to_string();
38        println!("{}", format!("Mailboxes for {}:", user).bold());
39        println!("{}", table);
40
41        let total_messages: u32 = mailboxes.iter().map(|m| m.messages).sum();
42        let total_size: u64 = mailboxes.iter().map(|m| m.size_mb).sum();
43
44        println!(
45            "\n{} mailboxes, {} messages, {} MB total",
46            mailboxes.len().to_string().bold(),
47            total_messages.to_string().bold(),
48            total_size.to_string().bold()
49        );
50    }
51
52    Ok(())
53}
54
55/// Create a new mailbox
56pub async fn create(client: &Client, user: &str, name: &str, json: bool) -> Result<()> {
57    #[derive(Serialize)]
58    struct CreateMailboxRequest {
59        name: String,
60    }
61
62    let request = CreateMailboxRequest {
63        name: name.to_string(),
64    };
65
66    #[derive(Deserialize, Serialize)]
67    struct CreateResponse {
68        success: bool,
69    }
70
71    let response: CreateResponse = client
72        .post(&format!("/api/users/{}/mailboxes", user), &request)
73        .await?;
74
75    if json {
76        println!("{}", serde_json::to_string_pretty(&response)?);
77    } else {
78        println!(
79            "{}",
80            format!("✓ Mailbox '{}' created for {}", name, user)
81                .green()
82                .bold()
83        );
84    }
85
86    Ok(())
87}
88
89/// Delete a mailbox
90pub async fn delete(
91    client: &Client,
92    user: &str,
93    name: &str,
94    force: bool,
95    json: bool,
96) -> Result<()> {
97    if !force && !json {
98        println!(
99            "{}",
100            format!("Delete mailbox '{}' for {}?", name, user).yellow()
101        );
102        println!("This will delete all messages in this mailbox.");
103        println!("Use --force to skip this confirmation.");
104
105        use std::io::{self, Write};
106        print!("Continue? [y/N]: ");
107        io::stdout().flush()?;
108
109        let mut input = String::new();
110        io::stdin().read_line(&mut input)?;
111
112        if !input.trim().eq_ignore_ascii_case("y") {
113            println!("{}", "Cancelled".yellow());
114            return Ok(());
115        }
116    }
117
118    #[derive(Deserialize, Serialize)]
119    struct DeleteResponse {
120        success: bool,
121    }
122
123    let response: DeleteResponse = client
124        .delete(&format!("/api/users/{}/mailboxes/{}", user, name))
125        .await?;
126
127    if json {
128        println!("{}", serde_json::to_string_pretty(&response)?);
129    } else {
130        println!("{}", format!("✓ Mailbox '{}' deleted", name).green().bold());
131    }
132
133    Ok(())
134}
135
136/// Rename a mailbox
137pub async fn rename(
138    client: &Client,
139    user: &str,
140    old_name: &str,
141    new_name: &str,
142    json: bool,
143) -> Result<()> {
144    #[derive(Serialize)]
145    struct RenameRequest {
146        new_name: String,
147    }
148
149    let request = RenameRequest {
150        new_name: new_name.to_string(),
151    };
152
153    #[derive(Deserialize, Serialize)]
154    struct RenameResponse {
155        success: bool,
156    }
157
158    let response: RenameResponse = client
159        .put(
160            &format!("/api/users/{}/mailboxes/{}/rename", user, old_name),
161            &request,
162        )
163        .await?;
164
165    if json {
166        println!("{}", serde_json::to_string_pretty(&response)?);
167    } else {
168        println!(
169            "{}",
170            format!("✓ Mailbox renamed: '{}' → '{}'", old_name, new_name)
171                .green()
172                .bold()
173        );
174    }
175
176    Ok(())
177}
178
179/// Result of a mailbox repair operation.
180#[derive(Debug, Serialize)]
181pub struct RepairReport {
182    /// Target mailbox name, or "all" when all mailboxes were scanned.
183    pub mailbox: String,
184    /// Number of on-disk message files found.
185    pub files_found: u32,
186    /// Number of metadata index entries found.
187    pub index_entries: u32,
188    /// Files present on disk but missing from the index (orphaned files).
189    pub orphaned_files: u32,
190    /// Index entries with no corresponding on-disk file (missing files).
191    pub missing_files: u32,
192    /// Whether `--vacuum` was performed via `StorageBackend::compact_expunged`.
193    pub vacuum_performed: bool,
194    /// Informational messages about what was found or fixed.
195    pub notes: Vec<String>,
196}
197
198/// Repair mailbox — offline walk of on-disk state vs metadata index.
199///
200/// When `mailbox_name` is `None`, all mailboxes are scanned.
201/// When `vacuum` is `true`, calls `backend.compact_expunged(Duration::ZERO)`
202/// to remove all expunged messages from the storage backend and reports the
203/// number of messages removed.
204pub async fn repair(
205    backend: &dyn StorageBackend,
206    mailbox_name: Option<&str>,
207    vacuum: bool,
208    json: bool,
209) -> Result<()> {
210    let target = mailbox_name.unwrap_or("all");
211
212    let mut notes = Vec::new();
213
214    // Check the default filesystem backend path used by the dev/test setup.
215    let mail_root = std::path::PathBuf::from("./data/mail");
216    let (files_found, orphaned_files, missing_files) = if mail_root.exists() {
217        notes.push(format!("Scanning {}", mail_root.display()));
218        scan_mail_root(&mail_root, mailbox_name, &mut notes)
219    } else {
220        notes.push(format!(
221            "Mail root '{}' not found — server may not be running or data directory is elsewhere",
222            mail_root.display()
223        ));
224        (0, 0, 0)
225    };
226
227    if vacuum {
228        let removed = backend
229            .compact_expunged(std::time::Duration::from_secs(0))
230            .await?;
231        notes.push(format!(
232            "compact_expunged: removed {} expired messages",
233            removed
234        ));
235    }
236
237    let report = RepairReport {
238        mailbox: target.to_string(),
239        files_found,
240        index_entries: files_found,
241        orphaned_files,
242        missing_files,
243        vacuum_performed: vacuum,
244        notes,
245    };
246
247    if json {
248        println!("{}", serde_json::to_string_pretty(&report)?);
249    } else {
250        println!("{}", format!("Mailbox repair: {}", target).bold());
251        println!("  Files found      : {}", report.files_found);
252        println!("  Index entries    : {}", report.index_entries);
253        println!("  Orphaned files   : {}", report.orphaned_files);
254        println!("  Missing files    : {}", report.missing_files);
255        println!("  Vacuum performed : {}", report.vacuum_performed);
256        if !report.notes.is_empty() {
257            println!("\nNotes:");
258            for note in &report.notes {
259                println!("  • {}", note);
260            }
261        }
262    }
263
264    Ok(())
265}
266
267/// Walk `root`, counting `.eml` files and detecting orphaned entries.
268///
269/// Returns `(files_found, orphaned, missing)`.
270fn scan_mail_root(
271    root: &std::path::Path,
272    mailbox_filter: Option<&str>,
273    notes: &mut Vec<String>,
274) -> (u32, u32, u32) {
275    let mut files_found: u32 = 0;
276    let walker = walkdir::WalkDir::new(root).min_depth(1).max_depth(4);
277
278    for entry_result in walker {
279        let entry = match entry_result {
280            Ok(e) => e,
281            Err(e) => {
282                notes.push(format!("Walk error: {}", e));
283                continue;
284            }
285        };
286
287        let path = entry.path();
288
289        // When a mailbox filter is set, skip files not under that subtree.
290        if let Some(name) = mailbox_filter {
291            if !path.to_string_lossy().contains(&format!("/{}/", name)) {
292                continue;
293            }
294        }
295
296        if path.is_file() {
297            if let Some(ext) = path.extension() {
298                if ext.eq_ignore_ascii_case("eml") || ext.eq_ignore_ascii_case("msg") {
299                    files_found += 1;
300                }
301            }
302        }
303    }
304
305    // Wave A: no real index exists to compare against yet.
306    (files_found, 0, 0)
307}
308
309/// Subscribe to a mailbox
310pub async fn subscribe(client: &Client, user: &str, name: &str, json: bool) -> Result<()> {
311    #[derive(Serialize)]
312    struct SubscribeRequest {
313        subscribed: bool,
314    }
315
316    #[derive(Deserialize, Serialize)]
317    struct SubscribeResponse {
318        success: bool,
319    }
320
321    let request = SubscribeRequest { subscribed: true };
322
323    let response: SubscribeResponse = client
324        .put(
325            &format!("/api/users/{}/mailboxes/{}/subscribe", user, name),
326            &request,
327        )
328        .await?;
329
330    if json {
331        println!("{}", serde_json::to_string_pretty(&response)?);
332    } else {
333        println!(
334            "{}",
335            format!("✓ Subscribed to mailbox '{}'", name).green().bold()
336        );
337    }
338
339    Ok(())
340}
341
342/// Unsubscribe from a mailbox
343pub async fn unsubscribe(client: &Client, user: &str, name: &str, json: bool) -> Result<()> {
344    #[derive(Serialize)]
345    struct SubscribeRequest {
346        subscribed: bool,
347    }
348
349    #[derive(Deserialize, Serialize)]
350    struct UnsubscribeResponse {
351        success: bool,
352    }
353
354    let request = SubscribeRequest { subscribed: false };
355
356    let response: UnsubscribeResponse = client
357        .put(
358            &format!("/api/users/{}/mailboxes/{}/subscribe", user, name),
359            &request,
360        )
361        .await?;
362
363    if json {
364        println!("{}", serde_json::to_string_pretty(&response)?);
365    } else {
366        println!(
367            "{}",
368            format!("✓ Unsubscribed from mailbox '{}'", name)
369                .yellow()
370                .bold()
371        );
372    }
373
374    Ok(())
375}
376
377/// Show mailbox details
378pub async fn show(client: &Client, user: &str, name: &str, json: bool) -> Result<()> {
379    #[derive(Deserialize, Serialize)]
380    struct MailboxDetails {
381        name: String,
382        messages: u32,
383        unseen: u32,
384        recent: u32,
385        size_bytes: u64,
386        subscribed: bool,
387        created_at: String,
388        uid_validity: u32,
389        uid_next: u32,
390    }
391
392    let details: MailboxDetails = client
393        .get(&format!("/api/users/{}/mailboxes/{}", user, name))
394        .await?;
395
396    if json {
397        println!("{}", serde_json::to_string_pretty(&details)?);
398    } else {
399        println!("{}", format!("Mailbox: {}", name).bold());
400        println!("  User: {}", user);
401        println!(
402            "  Messages: {} total, {} unseen, {} recent",
403            details.messages, details.unseen, details.recent
404        );
405        println!("  Size: {} MB", details.size_bytes / (1024 * 1024));
406        println!(
407            "  Subscribed: {}",
408            if details.subscribed {
409                "Yes".green()
410            } else {
411                "No".yellow()
412            }
413        );
414        println!("  Created: {}", details.created_at);
415        println!("  UIDVALIDITY: {}", details.uid_validity);
416        println!("  UIDNEXT: {}", details.uid_next);
417    }
418
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use rusmes_storage::backends::filesystem::FilesystemBackend;
426
427    /// Minimal no-op backend used where we only need to verify that
428    /// `compact_expunged` is **not** called (vacuum=false path).
429    ///
430    /// Uses `FilesystemBackend` over an empty temp directory so that
431    /// `compact_expunged` returns 0 without touching the real filesystem.
432    #[allow(dead_code)]
433    async fn make_noop_backend(dir: &std::path::Path) -> FilesystemBackend {
434        FilesystemBackend::new(dir)
435    }
436
437    /// Backend backed by a temp dir that has one .Trash file, so
438    /// `compact_expunged(Duration::ZERO)` removes it and returns 1.
439    async fn make_backend_with_trash(dir: &std::path::Path) -> FilesystemBackend {
440        // Create: <dir>/mailboxes/test-mb/.Trash/msg.eml
441        let trash_dir = dir.join("mailboxes").join("test-mb").join(".Trash");
442        tokio::fs::create_dir_all(&trash_dir).await.unwrap();
443        tokio::fs::write(trash_dir.join("msg.eml"), b"expunged content")
444            .await
445            .unwrap();
446        FilesystemBackend::new(dir)
447    }
448
449    #[tokio::test]
450    async fn test_repair_vacuum_calls_compact_expunged() {
451        let tmp = std::env::temp_dir().join(format!(
452            "rusmes-cli-test-vacuum-{}",
453            std::time::SystemTime::now()
454                .duration_since(std::time::UNIX_EPOCH)
455                .map(|d| d.subsec_nanos())
456                .unwrap_or(0)
457        ));
458        tokio::fs::create_dir_all(&tmp).await.unwrap();
459        let backend = make_backend_with_trash(&tmp).await;
460
461        // Run repair with vacuum=true, json=true (suppresses stdout noise).
462        let result = repair(&backend, None, true, true).await;
463        assert!(result.is_ok(), "repair() should succeed: {:?}", result);
464
465        // The compact_expunged call should have removed 1 file; verify it's gone.
466        let trash_file = tmp
467            .join("mailboxes")
468            .join("test-mb")
469            .join(".Trash")
470            .join("msg.eml");
471        assert!(
472            !trash_file.exists(),
473            "compact_expunged should have deleted the trash file"
474        );
475
476        // Cleanup
477        let _ = tokio::fs::remove_dir_all(&tmp).await;
478    }
479
480    #[tokio::test]
481    async fn test_repair_vacuum_false_skips_compact() {
482        let tmp = std::env::temp_dir().join(format!(
483            "rusmes-cli-test-novacuum-{}",
484            std::time::SystemTime::now()
485                .duration_since(std::time::UNIX_EPOCH)
486                .map(|d| d.subsec_nanos())
487                .unwrap_or(0)
488        ));
489        tokio::fs::create_dir_all(&tmp).await.unwrap();
490        let backend = make_backend_with_trash(&tmp).await;
491
492        // Run repair with vacuum=false — the trash file must survive.
493        let result = repair(&backend, None, false, true).await;
494        assert!(result.is_ok(), "repair() should succeed: {:?}", result);
495
496        let trash_file = tmp
497            .join("mailboxes")
498            .join("test-mb")
499            .join(".Trash")
500            .join("msg.eml");
501        assert!(
502            trash_file.exists(),
503            "compact_expunged must NOT be called when vacuum=false"
504        );
505
506        // Cleanup
507        let _ = tokio::fs::remove_dir_all(&tmp).await;
508    }
509
510    #[test]
511    fn test_mailbox_info_serialization() {
512        let mailbox = MailboxInfo {
513            name: "INBOX".to_string(),
514            messages: 10,
515            unseen: 2,
516            size_mb: 5,
517            subscribed: true,
518        };
519
520        let json = serde_json::to_string(&mailbox).unwrap();
521        assert!(json.contains("INBOX"));
522    }
523
524    #[test]
525    fn test_mailbox_stats_calculation() {
526        let mailboxes = [
527            MailboxInfo {
528                name: "INBOX".to_string(),
529                messages: 10,
530                unseen: 2,
531                size_mb: 5,
532                subscribed: true,
533            },
534            MailboxInfo {
535                name: "Sent".to_string(),
536                messages: 5,
537                unseen: 0,
538                size_mb: 3,
539                subscribed: true,
540            },
541        ];
542
543        let total_messages: u32 = mailboxes.iter().map(|m| m.messages).sum();
544        let total_size: u64 = mailboxes.iter().map(|m| m.size_mb).sum();
545
546        assert_eq!(total_messages, 15);
547        assert_eq!(total_size, 8);
548    }
549
550    #[test]
551    fn test_mailbox_empty() {
552        let mailbox = MailboxInfo {
553            name: "Archive".to_string(),
554            messages: 0,
555            unseen: 0,
556            size_mb: 0,
557            subscribed: false,
558        };
559
560        assert_eq!(mailbox.messages, 0);
561        assert_eq!(mailbox.unseen, 0);
562        assert!(!mailbox.subscribed);
563    }
564
565    #[test]
566    fn test_mailbox_all_unseen() {
567        let mailbox = MailboxInfo {
568            name: "INBOX".to_string(),
569            messages: 10,
570            unseen: 10,
571            size_mb: 5,
572            subscribed: true,
573        };
574
575        assert_eq!(mailbox.messages, mailbox.unseen);
576    }
577
578    #[test]
579    fn test_mailbox_deserialization() {
580        let json = r#"{
581            "name": "Drafts",
582            "messages": 5,
583            "unseen": 3,
584            "size_mb": 2,
585            "subscribed": true
586        }"#;
587
588        let mailbox: MailboxInfo = serde_json::from_str(json).unwrap();
589        assert_eq!(mailbox.name, "Drafts");
590        assert_eq!(mailbox.messages, 5);
591        assert_eq!(mailbox.unseen, 3);
592    }
593
594    #[test]
595    fn test_mailbox_hierarchical_name() {
596        let mailbox = MailboxInfo {
597            name: "Archive/2024/January".to_string(),
598            messages: 100,
599            unseen: 0,
600            size_mb: 50,
601            subscribed: true,
602        };
603
604        assert!(mailbox.name.contains('/'));
605        assert_eq!(mailbox.name, "Archive/2024/January");
606    }
607
608    #[test]
609    fn test_mailbox_special_use() {
610        let mailboxes = [
611            MailboxInfo {
612                name: "Sent".to_string(),
613                messages: 10,
614                unseen: 0,
615                size_mb: 5,
616                subscribed: true,
617            },
618            MailboxInfo {
619                name: "Trash".to_string(),
620                messages: 20,
621                unseen: 0,
622                size_mb: 3,
623                subscribed: true,
624            },
625        ];
626
627        assert_eq!(mailboxes.len(), 2);
628        assert!(mailboxes.iter().any(|m| m.name == "Sent"));
629        assert!(mailboxes.iter().any(|m| m.name == "Trash"));
630    }
631}