1use anyhow::Result;
4use colored::*;
5use rusmes_storage::StorageBackend;
6use serde::{Deserialize, Serialize};
7use tabled::{Table, Tabled};
8
9use crate::client::Client;
10
11use 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
23pub 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
55pub 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
89pub 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
136pub 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#[derive(Debug, Serialize)]
181pub struct RepairReport {
182 pub mailbox: String,
184 pub files_found: u32,
186 pub index_entries: u32,
188 pub orphaned_files: u32,
190 pub missing_files: u32,
192 pub vacuum_performed: bool,
194 pub notes: Vec<String>,
196}
197
198pub 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 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
267fn 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 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 (files_found, 0, 0)
307}
308
309pub 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
342pub 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
377pub 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 #[allow(dead_code)]
433 async fn make_noop_backend(dir: &std::path::Path) -> FilesystemBackend {
434 FilesystemBackend::new(dir)
435 }
436
437 async fn make_backend_with_trash(dir: &std::path::Path) -> FilesystemBackend {
440 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 let result = repair(&backend, None, true, true).await;
463 assert!(result.is_ok(), "repair() should succeed: {:?}", result);
464
465 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 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 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 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}