1pub mod action;
2pub mod app;
3pub mod client;
4pub mod desktop_manifest;
5pub mod input;
6pub mod keybindings;
7pub mod theme;
8pub mod ui;
9
10use app::{App, AttachmentOperation, ComposeAction, PendingSend};
11use client::Client;
12use crossterm::event::{Event, EventStream};
13use futures::StreamExt;
14use mxr_config::{load_config, socket_path as config_socket_path};
15use mxr_core::MxrError;
16use mxr_protocol::{DaemonEvent, Request, Response, ResponseData};
17use tokio::sync::{mpsc, oneshot};
18
19struct IpcRequest {
21 request: Request,
22 reply: oneshot::Sender<Result<Response, MxrError>>,
23}
24
25fn spawn_ipc_worker(
29 socket_path: std::path::PathBuf,
30 result_tx: mpsc::UnboundedSender<AsyncResult>,
31) -> mpsc::UnboundedSender<IpcRequest> {
32 let (tx, mut rx) = mpsc::unbounded_channel::<IpcRequest>();
33 tokio::spawn(async move {
34 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<DaemonEvent>();
36 let mut client = match connect_ipc_client(&socket_path, event_tx.clone()).await {
37 Ok(client) => client,
38 Err(_) => return,
39 };
40
41 loop {
42 tokio::select! {
43 req = rx.recv() => {
44 match req {
45 Some(req) => {
46 let mut result = client.raw_request(req.request.clone()).await;
47 if should_reconnect_ipc(&result)
48 && request_supports_retry(&req.request)
49 {
50 match connect_ipc_client(&socket_path, event_tx.clone()).await {
51 Ok(mut reconnected) => {
52 let retry = reconnected.raw_request(req.request.clone()).await;
53 if retry.is_ok() {
54 client = reconnected;
55 }
56 result = retry;
57 }
58 Err(error) => {
59 result = Err(error);
60 }
61 }
62 }
63 let _ = req.reply.send(result);
64 }
65 None => break,
66 }
67 }
68 event = event_rx.recv() => {
69 if let Some(event) = event {
70 let _ = result_tx.send(AsyncResult::DaemonEvent(event));
71 }
72 }
73 }
74 }
75 });
76 tx
77}
78
79async fn connect_ipc_client(
80 socket_path: &std::path::Path,
81 event_tx: mpsc::UnboundedSender<DaemonEvent>,
82) -> Result<Client, MxrError> {
83 Client::connect(socket_path)
84 .await
85 .map(|client| client.with_event_channel(event_tx))
86 .map_err(|e| MxrError::Ipc(e.to_string()))
87}
88
89fn should_reconnect_ipc(result: &Result<Response, MxrError>) -> bool {
90 match result {
91 Err(MxrError::Ipc(message)) => {
92 let lower = message.to_lowercase();
93 lower.contains("broken pipe") || lower.contains("connection closed")
94 }
95 _ => false,
96 }
97}
98
99fn request_supports_retry(request: &Request) -> bool {
100 matches!(
101 request,
102 Request::ListEnvelopes { .. }
103 | Request::ListEnvelopesByIds { .. }
104 | Request::GetEnvelope { .. }
105 | Request::GetBody { .. }
106 | Request::ListBodies { .. }
107 | Request::GetThread { .. }
108 | Request::ListLabels { .. }
109 | Request::ListRules
110 | Request::ListAccounts
111 | Request::ListAccountsConfig
112 | Request::GetRule { .. }
113 | Request::GetRuleForm { .. }
114 | Request::DryRunRules { .. }
115 | Request::ListEvents { .. }
116 | Request::GetLogs { .. }
117 | Request::GetDoctorReport
118 | Request::GenerateBugReport { .. }
119 | Request::ListRuleHistory { .. }
120 | Request::Search { .. }
121 | Request::GetSyncStatus { .. }
122 | Request::Count { .. }
123 | Request::GetHeaders { .. }
124 | Request::ListSavedSearches
125 | Request::ListSubscriptions { .. }
126 | Request::RunSavedSearch { .. }
127 | Request::ListSnoozed
128 | Request::PrepareReply { .. }
129 | Request::PrepareForward { .. }
130 | Request::ListDrafts
131 | Request::GetStatus
132 | Request::Ping
133 )
134}
135
136async fn ipc_call(
138 tx: &mpsc::UnboundedSender<IpcRequest>,
139 request: Request,
140) -> Result<Response, MxrError> {
141 let (reply_tx, reply_rx) = oneshot::channel();
142 tx.send(IpcRequest {
143 request,
144 reply: reply_tx,
145 })
146 .map_err(|_| MxrError::Ipc("IPC worker closed".into()))?;
147 reply_rx
148 .await
149 .map_err(|_| MxrError::Ipc("IPC worker dropped".into()))?
150}
151
152fn open_tui_log_file() -> Result<String, MxrError> {
153 let log_path = mxr_config::data_dir().join("logs").join("mxr.log");
154 if !log_path.exists() {
155 return Err(MxrError::Ipc(format!(
156 "log file not found at {}",
157 log_path.display()
158 )));
159 }
160
161 let editor = load_config()
162 .ok()
163 .and_then(|config| config.general.editor)
164 .map(|editor| mxr_compose::editor::resolve_editor(Some(editor.as_str())))
165 .unwrap_or_else(|| mxr_compose::editor::resolve_editor(None));
166 let status = std::process::Command::new(&editor)
167 .arg(&log_path)
168 .status()
169 .map_err(|error| MxrError::Ipc(format!("failed to launch editor: {error}")))?;
170
171 if !status.success() {
172 return Ok("Log open cancelled".into());
173 }
174
175 Ok(format!("Opened logs at {}", log_path.display()))
176}
177
178fn open_temp_text_buffer(name: &str, content: &str) -> Result<String, MxrError> {
179 let path = std::env::temp_dir().join(format!(
180 "mxr-{}-{}.txt",
181 name,
182 chrono::Utc::now().format("%Y%m%d-%H%M%S")
183 ));
184 std::fs::write(&path, content)
185 .map_err(|error| MxrError::Ipc(format!("failed to write temp file: {error}")))?;
186
187 let editor = load_config()
188 .ok()
189 .and_then(|config| config.general.editor)
190 .map(|editor| mxr_compose::editor::resolve_editor(Some(editor.as_str())))
191 .unwrap_or_else(|| mxr_compose::editor::resolve_editor(None));
192 let status = std::process::Command::new(&editor)
193 .arg(&path)
194 .status()
195 .map_err(|error| MxrError::Ipc(format!("failed to launch editor: {error}")))?;
196
197 if !status.success() {
198 return Ok(format!(
199 "Diagnostics detail open cancelled ({})",
200 path.display()
201 ));
202 }
203
204 Ok(format!("Opened diagnostics details at {}", path.display()))
205}
206
207fn open_diagnostics_pane_details(
208 state: &app::DiagnosticsPageState,
209 pane: app::DiagnosticsPaneKind,
210) -> Result<String, MxrError> {
211 if pane == app::DiagnosticsPaneKind::Logs {
212 return open_tui_log_file();
213 }
214
215 let name = match pane {
216 app::DiagnosticsPaneKind::Status => "doctor",
217 app::DiagnosticsPaneKind::Data => "storage",
218 app::DiagnosticsPaneKind::Sync => "sync-health",
219 app::DiagnosticsPaneKind::Events => "events",
220 app::DiagnosticsPaneKind::Logs => "logs",
221 };
222 let content = crate::ui::diagnostics_page::pane_details_text(state, pane);
223 open_temp_text_buffer(name, &content)
224}
225
226pub async fn run() -> anyhow::Result<()> {
227 let socket_path = daemon_socket_path();
228 let mut client = Client::connect(&socket_path).await?;
229 let config = load_config()?;
230
231 let mut app = App::from_config(&config);
232 if config.accounts.is_empty() {
233 app.accounts_page.refresh_pending = true;
234 } else {
235 app.load(&mut client).await?;
236 }
237
238 let mut terminal = ratatui::init();
239 let mut events = EventStream::new();
240
241 let (result_tx, mut result_rx) = mpsc::unbounded_channel::<AsyncResult>();
243
244 let bg = spawn_ipc_worker(socket_path, result_tx.clone());
246
247 loop {
248 if app.pending_log_open {
249 app.pending_log_open = false;
250 ratatui::restore();
251 let result = open_tui_log_file();
252 terminal = ratatui::init();
253 match result {
254 Ok(message) => {
255 app.status_message = Some(message);
256 }
257 Err(error) => {
258 app.error_modal = Some(app::ErrorModalState {
259 title: "Open Logs Failed".into(),
260 detail: format!(
261 "The log file could not be opened.\n\n{error}\n\nCheck that the daemon has created the log file and try again."
262 ),
263 });
264 app.status_message = Some(format!("Open logs failed: {error}"));
265 }
266 }
267 }
268 if let Some(pane) = app.pending_diagnostics_details.take() {
269 ratatui::restore();
270 let result = open_diagnostics_pane_details(&app.diagnostics_page, pane);
271 terminal = ratatui::init();
272 match result {
273 Ok(message) => {
274 app.status_message = Some(message);
275 }
276 Err(error) => {
277 app.error_modal = Some(app::ErrorModalState {
278 title: "Diagnostics Open Failed".into(),
279 detail: format!(
280 "The diagnostics source could not be opened.\n\n{error}\n\nTry refresh first, then open details again."
281 ),
282 });
283 app.status_message = Some(format!("Open diagnostics failed: {error}"));
284 }
285 }
286 }
287
288 if !app.queued_body_fetches.is_empty() {
291 let ids = std::mem::take(&mut app.queued_body_fetches);
292 let bg = bg.clone();
293 let tx = result_tx.clone();
294 tokio::spawn(async move {
295 let requested = ids;
296 let resp = ipc_call(
297 &bg,
298 Request::ListBodies {
299 message_ids: requested.clone(),
300 },
301 )
302 .await;
303 let result = match resp {
304 Ok(Response::Ok {
305 data: ResponseData::Bodies { bodies },
306 }) => Ok(bodies),
307 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
308 Err(e) => Err(e),
309 _ => Err(MxrError::Ipc("unexpected response".into())),
310 };
311 let _ = tx.send(AsyncResult::Bodies { requested, result });
312 });
313 }
314
315 if let Some(thread_id) = app.pending_thread_fetch.take() {
316 app.in_flight_thread_fetch = Some(thread_id.clone());
317 let bg = bg.clone();
318 let tx = result_tx.clone();
319 tokio::spawn(async move {
320 let resp = ipc_call(
321 &bg,
322 Request::GetThread {
323 thread_id: thread_id.clone(),
324 },
325 )
326 .await;
327 let result = match resp {
328 Ok(Response::Ok {
329 data: ResponseData::Thread { thread, messages },
330 }) => Ok((thread, messages)),
331 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
332 Err(e) => Err(e),
333 _ => Err(MxrError::Ipc("unexpected response".into())),
334 };
335 let _ = tx.send(AsyncResult::Thread { thread_id, result });
336 });
337 }
338
339 terminal.draw(|frame| app.draw(frame))?;
340
341 let timeout = if app.input_pending() {
342 std::time::Duration::from_millis(500)
343 } else {
344 std::time::Duration::from_secs(60)
345 };
346 let timeout = app.next_background_timeout(timeout);
347
348 if let Some((query, mode)) = app.pending_search.take() {
350 let bg = bg.clone();
351 let tx = result_tx.clone();
352 tokio::spawn(async move {
353 let results = match ipc_call(
354 &bg,
355 Request::Search {
356 query,
357 limit: 200,
358 mode: Some(mode),
359 explain: false,
360 },
361 )
362 .await
363 {
364 Ok(Response::Ok {
365 data: ResponseData::SearchResults { results, .. },
366 }) => {
367 let mut scores = std::collections::HashMap::new();
368 let message_ids = results
369 .into_iter()
370 .map(|result| {
371 scores.insert(result.message_id.clone(), result.score);
372 result.message_id
373 })
374 .collect::<Vec<_>>();
375 if message_ids.is_empty() {
376 Ok(SearchResultData {
377 envelopes: Vec::new(),
378 scores,
379 })
380 } else {
381 match ipc_call(&bg, Request::ListEnvelopesByIds { message_ids }).await {
382 Ok(Response::Ok {
383 data: ResponseData::Envelopes { envelopes },
384 }) => Ok(SearchResultData { envelopes, scores }),
385 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
386 Err(e) => Err(e),
387 _ => Err(MxrError::Ipc("unexpected response".into())),
388 }
389 }
390 }
391 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
392 Err(e) => Err(e),
393 _ => Err(MxrError::Ipc("unexpected response".into())),
394 };
395 let _ = tx.send(AsyncResult::Search(results));
396 });
397 }
398
399 if let Some(pending) = app.pending_unsubscribe_action.take() {
400 let bg = bg.clone();
401 let tx = result_tx.clone();
402 tokio::spawn(async move {
403 let unsubscribe_resp = ipc_call(
404 &bg,
405 Request::Unsubscribe {
406 message_id: pending.message_id.clone(),
407 },
408 )
409 .await;
410 let unsubscribe_result = match unsubscribe_resp {
411 Ok(Response::Ok {
412 data: ResponseData::Ack,
413 }) => Ok(()),
414 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
415 Err(error) => Err(error),
416 _ => Err(MxrError::Ipc("unexpected response".into())),
417 };
418
419 let result = match unsubscribe_result {
420 Ok(()) if pending.archive_message_ids.is_empty() => Ok(UnsubscribeResultData {
421 archived_ids: Vec::new(),
422 message: format!("Unsubscribed from {}", pending.sender_email),
423 }),
424 Ok(()) => {
425 let archived_count = pending.archive_message_ids.len();
426 let archive_resp = ipc_call(
427 &bg,
428 Request::Mutation(mxr_protocol::MutationCommand::Archive {
429 message_ids: pending.archive_message_ids.clone(),
430 }),
431 )
432 .await;
433 match archive_resp {
434 Ok(Response::Ok {
435 data: ResponseData::Ack,
436 }) => Ok(UnsubscribeResultData {
437 archived_ids: pending.archive_message_ids,
438 message: format!(
439 "Unsubscribed and archived {} messages from {}",
440 archived_count, pending.sender_email
441 ),
442 }),
443 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
444 Err(error) => Err(error),
445 _ => Err(MxrError::Ipc("unexpected response".into())),
446 }
447 }
448 Err(error) => Err(error),
449 };
450 let _ = tx.send(AsyncResult::Unsubscribe(result));
451 });
452 }
453
454 if app.rules_page.refresh_pending {
455 app.rules_page.refresh_pending = false;
456 let bg = bg.clone();
457 let tx = result_tx.clone();
458 tokio::spawn(async move {
459 let resp = ipc_call(&bg, Request::ListRules).await;
460 let result = match resp {
461 Ok(Response::Ok {
462 data: ResponseData::Rules { rules },
463 }) => Ok(rules),
464 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
465 Err(e) => Err(e),
466 _ => Err(MxrError::Ipc("unexpected response".into())),
467 };
468 let _ = tx.send(AsyncResult::Rules(result));
469 });
470 }
471
472 if let Some(rule) = app.pending_rule_detail.take() {
473 let bg = bg.clone();
474 let tx = result_tx.clone();
475 tokio::spawn(async move {
476 let resp = ipc_call(&bg, Request::GetRule { rule }).await;
477 let result = match resp {
478 Ok(Response::Ok {
479 data: ResponseData::RuleData { rule },
480 }) => Ok(rule),
481 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
482 Err(e) => Err(e),
483 _ => Err(MxrError::Ipc("unexpected response".into())),
484 };
485 let _ = tx.send(AsyncResult::RuleDetail(result));
486 });
487 }
488
489 if let Some(rule) = app.pending_rule_history.take() {
490 let bg = bg.clone();
491 let tx = result_tx.clone();
492 tokio::spawn(async move {
493 let resp = ipc_call(
494 &bg,
495 Request::ListRuleHistory {
496 rule: Some(rule),
497 limit: 20,
498 },
499 )
500 .await;
501 let result = match resp {
502 Ok(Response::Ok {
503 data: ResponseData::RuleHistory { entries },
504 }) => Ok(entries),
505 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
506 Err(e) => Err(e),
507 _ => Err(MxrError::Ipc("unexpected response".into())),
508 };
509 let _ = tx.send(AsyncResult::RuleHistory(result));
510 });
511 }
512
513 if let Some(rule) = app.pending_rule_dry_run.take() {
514 let bg = bg.clone();
515 let tx = result_tx.clone();
516 tokio::spawn(async move {
517 let resp = ipc_call(
518 &bg,
519 Request::DryRunRules {
520 rule: Some(rule),
521 all: false,
522 after: None,
523 },
524 )
525 .await;
526 let result = match resp {
527 Ok(Response::Ok {
528 data: ResponseData::RuleDryRun { results },
529 }) => Ok(results),
530 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
531 Err(e) => Err(e),
532 _ => Err(MxrError::Ipc("unexpected response".into())),
533 };
534 let _ = tx.send(AsyncResult::RuleDryRun(result));
535 });
536 }
537
538 if let Some(rule) = app.pending_rule_form_load.take() {
539 let bg = bg.clone();
540 let tx = result_tx.clone();
541 tokio::spawn(async move {
542 let resp = ipc_call(&bg, Request::GetRuleForm { rule }).await;
543 let result = match resp {
544 Ok(Response::Ok {
545 data: ResponseData::RuleFormData { form },
546 }) => Ok(form),
547 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
548 Err(e) => Err(e),
549 _ => Err(MxrError::Ipc("unexpected response".into())),
550 };
551 let _ = tx.send(AsyncResult::RuleForm(result));
552 });
553 }
554
555 if let Some(rule) = app.pending_rule_delete.take() {
556 let bg = bg.clone();
557 let tx = result_tx.clone();
558 tokio::spawn(async move {
559 let resp = ipc_call(&bg, Request::DeleteRule { rule }).await;
560 let result = match resp {
561 Ok(Response::Ok { .. }) => Ok(()),
562 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
563 Err(e) => Err(e),
564 };
565 let _ = tx.send(AsyncResult::RuleDeleted(result));
566 });
567 }
568
569 if let Some(rule) = app.pending_rule_upsert.take() {
570 let bg = bg.clone();
571 let tx = result_tx.clone();
572 tokio::spawn(async move {
573 let resp = ipc_call(&bg, Request::UpsertRule { rule }).await;
574 let result = match resp {
575 Ok(Response::Ok {
576 data: ResponseData::RuleData { rule },
577 }) => Ok(rule),
578 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
579 Err(e) => Err(e),
580 _ => Err(MxrError::Ipc("unexpected response".into())),
581 };
582 let _ = tx.send(AsyncResult::RuleUpsert(result));
583 });
584 }
585
586 if app.pending_rule_form_save {
587 app.pending_rule_form_save = false;
588 let bg = bg.clone();
589 let tx = result_tx.clone();
590 let existing_rule = app.rules_page.form.existing_rule.clone();
591 let name = app.rules_page.form.name.clone();
592 let condition = app.rules_page.form.condition.clone();
593 let action = app.rules_page.form.action.clone();
594 let priority = app.rules_page.form.priority.parse::<i32>().unwrap_or(100);
595 let enabled = app.rules_page.form.enabled;
596 tokio::spawn(async move {
597 let resp = ipc_call(
598 &bg,
599 Request::UpsertRuleForm {
600 existing_rule,
601 name,
602 condition,
603 action,
604 priority,
605 enabled,
606 },
607 )
608 .await;
609 let result = match resp {
610 Ok(Response::Ok {
611 data: ResponseData::RuleData { rule },
612 }) => Ok(rule),
613 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
614 Err(e) => Err(e),
615 _ => Err(MxrError::Ipc("unexpected response".into())),
616 };
617 let _ = tx.send(AsyncResult::RuleUpsert(result));
618 });
619 }
620
621 if app.diagnostics_page.refresh_pending {
622 app.diagnostics_page.refresh_pending = false;
623 app.pending_status_refresh = false;
624 app.diagnostics_page.pending_requests = 4;
625 for request in [
626 Request::GetStatus,
627 Request::GetDoctorReport,
628 Request::ListEvents {
629 limit: 20,
630 level: None,
631 category: None,
632 },
633 Request::GetLogs {
634 limit: 50,
635 level: None,
636 },
637 ] {
638 let bg = bg.clone();
639 let tx = result_tx.clone();
640 tokio::spawn(async move {
641 let resp = ipc_call(&bg, request).await;
642 let _ = tx.send(AsyncResult::Diagnostics(Box::new(resp)));
643 });
644 }
645 }
646
647 if app.pending_status_refresh {
648 app.pending_status_refresh = false;
649 let bg = bg.clone();
650 let tx = result_tx.clone();
651 tokio::spawn(async move {
652 let resp = ipc_call(&bg, Request::GetStatus).await;
653 let result = match resp {
654 Ok(Response::Ok {
655 data:
656 ResponseData::Status {
657 uptime_secs,
658 daemon_pid,
659 accounts,
660 total_messages,
661 sync_statuses,
662 ..
663 },
664 }) => Ok(StatusSnapshot {
665 uptime_secs,
666 daemon_pid,
667 accounts,
668 total_messages,
669 sync_statuses,
670 }),
671 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
672 Err(e) => Err(e),
673 _ => Err(MxrError::Ipc("unexpected response".into())),
674 };
675 let _ = tx.send(AsyncResult::Status(result));
676 });
677 }
678
679 if app.accounts_page.refresh_pending {
680 app.accounts_page.refresh_pending = false;
681 let bg = bg.clone();
682 let tx = result_tx.clone();
683 tokio::spawn(async move {
684 let result = load_accounts_page_accounts(&bg).await;
685 let _ = tx.send(AsyncResult::Accounts(result));
686 });
687 }
688
689 if app.pending_labels_refresh {
690 app.pending_labels_refresh = false;
691 let bg = bg.clone();
692 let tx = result_tx.clone();
693 tokio::spawn(async move {
694 let resp = ipc_call(&bg, Request::ListLabels { account_id: None }).await;
695 let result = match resp {
696 Ok(Response::Ok {
697 data: ResponseData::Labels { labels },
698 }) => Ok(labels),
699 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
700 Err(e) => Err(e),
701 _ => Err(MxrError::Ipc("unexpected response".into())),
702 };
703 let _ = tx.send(AsyncResult::Labels(result));
704 });
705 }
706
707 if app.pending_all_envelopes_refresh {
708 app.pending_all_envelopes_refresh = false;
709 let bg = bg.clone();
710 let tx = result_tx.clone();
711 tokio::spawn(async move {
712 let resp = ipc_call(
713 &bg,
714 Request::ListEnvelopes {
715 label_id: None,
716 account_id: None,
717 limit: 5000,
718 offset: 0,
719 },
720 )
721 .await;
722 let result = match resp {
723 Ok(Response::Ok {
724 data: ResponseData::Envelopes { envelopes },
725 }) => Ok(envelopes),
726 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
727 Err(e) => Err(e),
728 _ => Err(MxrError::Ipc("unexpected response".into())),
729 };
730 let _ = tx.send(AsyncResult::AllEnvelopes(result));
731 });
732 }
733
734 if app.pending_subscriptions_refresh {
735 app.pending_subscriptions_refresh = false;
736 let bg = bg.clone();
737 let tx = result_tx.clone();
738 tokio::spawn(async move {
739 let resp = ipc_call(&bg, Request::ListSubscriptions { limit: 500 }).await;
740 let result = match resp {
741 Ok(Response::Ok {
742 data: ResponseData::Subscriptions { subscriptions },
743 }) => Ok(subscriptions),
744 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
745 Err(e) => Err(e),
746 _ => Err(MxrError::Ipc("unexpected response".into())),
747 };
748 let _ = tx.send(AsyncResult::Subscriptions(result));
749 });
750 }
751
752 if let Some(account) = app.pending_account_save.take() {
753 let bg = bg.clone();
754 let tx = result_tx.clone();
755 tokio::spawn(async move {
756 let result = run_account_save_workflow(&bg, account).await;
757 let _ = tx.send(AsyncResult::AccountOperation(result));
758 });
759 }
760
761 if let Some(account) = app.pending_account_test.take() {
762 let bg = bg.clone();
763 let tx = result_tx.clone();
764 tokio::spawn(async move {
765 let result =
766 request_account_operation(&bg, Request::TestAccountConfig { account }).await;
767 let _ = tx.send(AsyncResult::AccountOperation(result));
768 });
769 }
770
771 if let Some((account, reauthorize)) = app.pending_account_authorize.take() {
772 let bg = bg.clone();
773 let tx = result_tx.clone();
774 tokio::spawn(async move {
775 let result = request_account_operation(
776 &bg,
777 Request::AuthorizeAccountConfig {
778 account,
779 reauthorize,
780 },
781 )
782 .await;
783 let _ = tx.send(AsyncResult::AccountOperation(result));
784 });
785 }
786
787 if let Some(key) = app.pending_account_set_default.take() {
788 let bg = bg.clone();
789 let tx = result_tx.clone();
790 tokio::spawn(async move {
791 let result =
792 request_account_operation(&bg, Request::SetDefaultAccount { key }).await;
793 let _ = tx.send(AsyncResult::AccountOperation(result));
794 });
795 }
796
797 if app.pending_bug_report {
798 app.pending_bug_report = false;
799 let bg = bg.clone();
800 let tx = result_tx.clone();
801 tokio::spawn(async move {
802 let resp = ipc_call(
803 &bg,
804 Request::GenerateBugReport {
805 verbose: false,
806 full_logs: false,
807 since: None,
808 },
809 )
810 .await;
811 let result = match resp {
812 Ok(Response::Ok {
813 data: ResponseData::BugReport { content },
814 }) => Ok(content),
815 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
816 Err(e) => Err(e),
817 _ => Err(MxrError::Ipc("unexpected response".into())),
818 };
819 let _ = tx.send(AsyncResult::BugReport(result));
820 });
821 }
822
823 if let Some(pending) = app.pending_attachment_action.take() {
824 let bg = bg.clone();
825 let tx = result_tx.clone();
826 tokio::spawn(async move {
827 let request = match pending.operation {
828 AttachmentOperation::Open => Request::OpenAttachment {
829 message_id: pending.message_id,
830 attachment_id: pending.attachment_id,
831 },
832 AttachmentOperation::Download => Request::DownloadAttachment {
833 message_id: pending.message_id,
834 attachment_id: pending.attachment_id,
835 },
836 };
837 let resp = ipc_call(&bg, request).await;
838 let result = match resp {
839 Ok(Response::Ok {
840 data: ResponseData::AttachmentFile { file },
841 }) => Ok(file),
842 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
843 Err(e) => Err(e),
844 _ => Err(MxrError::Ipc("unexpected response".into())),
845 };
846 let _ = tx.send(AsyncResult::AttachmentFile {
847 operation: pending.operation,
848 result,
849 });
850 });
851 }
852
853 if let Some(label_id) = app.pending_label_fetch.take() {
855 let bg = bg.clone();
856 let tx = result_tx.clone();
857 tokio::spawn(async move {
858 let resp = ipc_call(
859 &bg,
860 Request::ListEnvelopes {
861 label_id: Some(label_id),
862 account_id: None,
863 limit: 5000,
864 offset: 0,
865 },
866 )
867 .await;
868 let envelopes = match resp {
869 Ok(Response::Ok {
870 data: ResponseData::Envelopes { envelopes },
871 }) => Ok(envelopes),
872 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
873 Err(e) => Err(e),
874 _ => Err(MxrError::Ipc("unexpected response".into())),
875 };
876 let _ = tx.send(AsyncResult::LabelEnvelopes(envelopes));
877 });
878 }
879
880 for (req, effect) in app.pending_mutation_queue.drain(..) {
882 let bg = bg.clone();
883 let tx = result_tx.clone();
884 tokio::spawn(async move {
885 let resp = ipc_call(&bg, req).await;
886 let result = match resp {
887 Ok(Response::Ok {
888 data: ResponseData::Ack,
889 }) => Ok(effect),
890 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
891 Err(e) => Err(e),
892 _ => Err(MxrError::Ipc("unexpected response".into())),
893 };
894 let _ = tx.send(AsyncResult::MutationResult(result));
895 });
896 }
897
898 if let Some(thread_id) = app.pending_export_thread.take() {
900 let bg = bg.clone();
901 let tx = result_tx.clone();
902 tokio::spawn(async move {
903 let resp = ipc_call(
904 &bg,
905 Request::ExportThread {
906 thread_id,
907 format: mxr_core::types::ExportFormat::Markdown,
908 },
909 )
910 .await;
911 let result = match resp {
912 Ok(Response::Ok {
913 data: ResponseData::ExportResult { content },
914 }) => {
915 let filename = format!(
917 "mxr-export-{}.md",
918 chrono::Utc::now().format("%Y%m%d-%H%M%S")
919 );
920 let path = std::env::temp_dir().join(&filename);
921 match std::fs::write(&path, &content) {
922 Ok(()) => Ok(format!("Exported to {}", path.display())),
923 Err(e) => Err(MxrError::Ipc(format!("Write failed: {e}"))),
924 }
925 }
926 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
927 Err(e) => Err(e),
928 _ => Err(MxrError::Ipc("unexpected response".into())),
929 };
930 let _ = tx.send(AsyncResult::ExportResult(result));
931 });
932 }
933
934 if let Some(compose_action) = app.pending_compose.take() {
936 let bg = bg.clone();
937 let tx = result_tx.clone();
938 tokio::spawn(async move {
939 let result = handle_compose_action(&bg, compose_action).await;
940 let _ = tx.send(AsyncResult::ComposeReady(result));
941 });
942 }
943
944 tokio::select! {
945 event = events.next() => {
946 if let Some(Ok(Event::Key(key))) = event {
947 if let Some(action) = app.handle_key(key) {
948 app.apply(action);
949 }
950 }
951 }
952 result = result_rx.recv() => {
953 if let Some(msg) = result {
954 match msg {
955 AsyncResult::Search(Ok(results)) => {
956 if app.screen == app::Screen::Search {
957 app.search_page.results = results.envelopes;
958 app.search_page.scores = results.scores;
959 app.search_page.selected_index = 0;
960 app.search_page.scroll_offset = 0;
961 app.auto_preview_search();
962 } else {
963 app.envelopes = results.envelopes;
964 app.selected_index = 0;
965 app.scroll_offset = 0;
966 }
967 }
968 AsyncResult::Search(Err(error)) => {
969 if app.screen == app::Screen::Search {
970 app.search_page.results.clear();
971 app.search_page.scores.clear();
972 } else {
973 app.envelopes = app.all_envelopes.clone();
974 }
975 app.status_message = Some(format!("Search failed: {error}"));
976 }
977 AsyncResult::Rules(Ok(rules)) => {
978 app.rules_page.rules = rules;
979 app.rules_page.selected_index = app
980 .rules_page
981 .selected_index
982 .min(app.rules_page.rules.len().saturating_sub(1));
983 if let Some(rule_id) = app
984 .selected_rule()
985 .and_then(|rule| rule["id"].as_str())
986 .map(ToString::to_string)
987 {
988 app.pending_rule_detail = Some(rule_id);
989 }
990 }
991 AsyncResult::Rules(Err(e)) => {
992 app.rules_page.status = Some(format!("Rules error: {e}"));
993 }
994 AsyncResult::RuleDetail(Ok(rule)) => {
995 app.rules_page.detail = Some(rule);
996 app.rules_page.panel = app::RulesPanel::Details;
997 }
998 AsyncResult::RuleDetail(Err(e)) => {
999 app.rules_page.status = Some(format!("Rule error: {e}"));
1000 }
1001 AsyncResult::RuleHistory(Ok(entries)) => {
1002 app.rules_page.history = entries;
1003 }
1004 AsyncResult::RuleHistory(Err(e)) => {
1005 app.rules_page.status = Some(format!("History error: {e}"));
1006 }
1007 AsyncResult::RuleDryRun(Ok(results)) => {
1008 app.rules_page.dry_run = results;
1009 }
1010 AsyncResult::RuleDryRun(Err(e)) => {
1011 app.rules_page.status = Some(format!("Dry-run error: {e}"));
1012 }
1013 AsyncResult::RuleForm(Ok(form)) => {
1014 app.rules_page.form.visible = true;
1015 app.rules_page.form.existing_rule = form.id;
1016 app.rules_page.form.name = form.name;
1017 app.rules_page.form.condition = form.condition;
1018 app.rules_page.form.action = form.action;
1019 app.rules_page.form.priority = form.priority.to_string();
1020 app.rules_page.form.enabled = form.enabled;
1021 app.rules_page.form.active_field = 0;
1022 app.rules_page.panel = app::RulesPanel::Form;
1023 }
1024 AsyncResult::RuleForm(Err(e)) => {
1025 app.rules_page.status = Some(format!("Form error: {e}"));
1026 }
1027 AsyncResult::RuleDeleted(Ok(())) => {
1028 app.rules_page.status = Some("Rule deleted".into());
1029 app.rules_page.refresh_pending = true;
1030 }
1031 AsyncResult::RuleDeleted(Err(e)) => {
1032 app.rules_page.status = Some(format!("Delete error: {e}"));
1033 }
1034 AsyncResult::RuleUpsert(Ok(rule)) => {
1035 app.rules_page.detail = Some(rule.clone());
1036 app.rules_page.form.visible = false;
1037 app.rules_page.panel = app::RulesPanel::Details;
1038 app.rules_page.status = Some("Rule saved".into());
1039 app.rules_page.refresh_pending = true;
1040 }
1041 AsyncResult::RuleUpsert(Err(e)) => {
1042 app.rules_page.status = Some(format!("Save error: {e}"));
1043 }
1044 AsyncResult::Diagnostics(result) => {
1045 app.diagnostics_page.pending_requests =
1046 app.diagnostics_page.pending_requests.saturating_sub(1);
1047 match *result {
1048 Ok(response) => match response {
1049 Response::Ok {
1050 data:
1051 ResponseData::Status {
1052 uptime_secs,
1053 daemon_pid,
1054 accounts,
1055 total_messages,
1056 sync_statuses,
1057 ..
1058 },
1059 } => {
1060 app.apply_status_snapshot(
1061 uptime_secs,
1062 daemon_pid,
1063 accounts,
1064 total_messages,
1065 sync_statuses,
1066 );
1067 }
1068 Response::Ok {
1069 data: ResponseData::DoctorReport { report },
1070 } => {
1071 app.diagnostics_page.doctor = Some(report);
1072 }
1073 Response::Ok {
1074 data: ResponseData::EventLogEntries { entries },
1075 } => {
1076 app.diagnostics_page.events = entries;
1077 }
1078 Response::Ok {
1079 data: ResponseData::LogLines { lines },
1080 } => {
1081 app.diagnostics_page.logs = lines;
1082 }
1083 Response::Error { message } => {
1084 app.diagnostics_page.status = Some(message);
1085 }
1086 _ => {}
1087 },
1088 Err(e) => {
1089 app.diagnostics_page.status =
1090 Some(format!("Diagnostics error: {e}"));
1091 }
1092 }
1093 }
1094 AsyncResult::Status(Ok(snapshot)) => {
1095 app.apply_status_snapshot(
1096 snapshot.uptime_secs,
1097 snapshot.daemon_pid,
1098 snapshot.accounts,
1099 snapshot.total_messages,
1100 snapshot.sync_statuses,
1101 );
1102 }
1103 AsyncResult::Status(Err(e)) => {
1104 app.status_message = Some(format!("Status refresh failed: {e}"));
1105 }
1106 AsyncResult::Accounts(Ok(accounts)) => {
1107 app.accounts_page.accounts = accounts;
1108 app.accounts_page.selected_index = app
1109 .accounts_page
1110 .selected_index
1111 .min(app.accounts_page.accounts.len().saturating_sub(1));
1112 if app.accounts_page.accounts.is_empty() {
1113 app.accounts_page.onboarding_required = true;
1114 } else {
1115 app.accounts_page.onboarding_required = false;
1116 app.accounts_page.onboarding_modal_open = false;
1117 }
1118 }
1119 AsyncResult::Accounts(Err(e)) => {
1120 app.accounts_page.status = Some(format!("Accounts error: {e}"));
1121 }
1122 AsyncResult::Labels(Ok(labels)) => {
1123 app.labels = labels;
1124 app.resolve_desired_system_mailbox();
1125 }
1126 AsyncResult::Labels(Err(e)) => {
1127 app.status_message = Some(format!("Label refresh failed: {e}"));
1128 }
1129 AsyncResult::AllEnvelopes(Ok(envelopes)) => {
1130 apply_all_envelopes_refresh(&mut app, envelopes);
1131 }
1132 AsyncResult::AllEnvelopes(Err(e)) => {
1133 app.status_message =
1134 Some(format!("Mailbox refresh failed: {e}"));
1135 }
1136 AsyncResult::AccountOperation(Ok(result)) => {
1137 app.accounts_page.status = Some(result.summary.clone());
1138 app.accounts_page.last_result = Some(result.clone());
1139 app.accounts_page.form.last_result = Some(result.clone());
1140 app.accounts_page.form.gmail_authorized = result
1141 .auth
1142 .as_ref()
1143 .map(|step| step.ok)
1144 .unwrap_or(app.accounts_page.form.gmail_authorized);
1145 if result.save.as_ref().is_some_and(|step| step.ok) {
1146 app.accounts_page.form.visible = false;
1147 }
1148 app.accounts_page.refresh_pending = true;
1149 }
1150 AsyncResult::AccountOperation(Err(e)) => {
1151 app.accounts_page.status = Some(format!("Account error: {e}"));
1152 }
1153 AsyncResult::BugReport(Ok(content)) => {
1154 let filename = format!(
1155 "mxr-bug-report-{}.md",
1156 chrono::Utc::now().format("%Y%m%d-%H%M%S")
1157 );
1158 let path = std::env::temp_dir().join(filename);
1159 match std::fs::write(&path, &content) {
1160 Ok(()) => {
1161 app.diagnostics_page.status =
1162 Some(format!("Bug report saved to {}", path.display()));
1163 }
1164 Err(e) => {
1165 app.diagnostics_page.status =
1166 Some(format!("Bug report write failed: {e}"));
1167 }
1168 }
1169 }
1170 AsyncResult::BugReport(Err(e)) => {
1171 app.diagnostics_page.status = Some(format!("Bug report error: {e}"));
1172 }
1173 AsyncResult::AttachmentFile {
1174 operation,
1175 result: Ok(file),
1176 } => {
1177 app.resolve_attachment_file(&file);
1178 let action = match operation {
1179 AttachmentOperation::Open => "Opened",
1180 AttachmentOperation::Download => "Downloaded",
1181 };
1182 let message = format!("{action} {} -> {}", file.filename, file.path);
1183 app.attachment_panel.status = Some(message.clone());
1184 app.status_message = Some(message);
1185 }
1186 AsyncResult::AttachmentFile {
1187 result: Err(e), ..
1188 } => {
1189 let message = format!("Attachment error: {e}");
1190 app.attachment_panel.status = Some(message.clone());
1191 app.status_message = Some(message);
1192 }
1193 AsyncResult::LabelEnvelopes(Ok(envelopes)) => {
1194 let selected_id =
1195 app.selected_mail_row().map(|row| row.representative.id);
1196 app.envelopes = envelopes;
1197 app.active_label = app.pending_active_label.take();
1198 restore_mail_list_selection(&mut app, selected_id);
1199 app.queue_body_window();
1200 }
1201 AsyncResult::LabelEnvelopes(Err(e)) => {
1202 app.pending_active_label = None;
1203 app.status_message = Some(format!("Label filter failed: {e}"));
1204 }
1205 AsyncResult::Bodies { requested, result: Ok(bodies) } => {
1206 let mut returned = std::collections::HashSet::new();
1207 for body in bodies {
1208 returned.insert(body.message_id.clone());
1209 app.resolve_body_success(body);
1210 }
1211 for message_id in requested {
1212 if !returned.contains(&message_id) {
1213 app.resolve_body_fetch_error(
1214 &message_id,
1215 "body not available".into(),
1216 );
1217 }
1218 }
1219 }
1220 AsyncResult::Bodies { requested, result: Err(e) } => {
1221 let message = e.to_string();
1222 for message_id in requested {
1223 app.resolve_body_fetch_error(&message_id, message.clone());
1224 }
1225 }
1226 AsyncResult::Thread {
1227 thread_id,
1228 result: Ok((thread, messages)),
1229 } => {
1230 app.resolve_thread_success(thread, messages);
1231 let _ = thread_id;
1232 }
1233 AsyncResult::Thread {
1234 thread_id,
1235 result: Err(_),
1236 } => {
1237 app.resolve_thread_fetch_error(&thread_id);
1238 }
1239 AsyncResult::MutationResult(Ok(effect)) => {
1240 app.finish_pending_mutation();
1241 let show_completion_status = app.pending_mutation_count == 0;
1242 match effect {
1243 app::MutationEffect::RemoveFromList(id) => {
1244 app.envelopes.retain(|e| e.id != id);
1245 app.all_envelopes.retain(|e| e.id != id);
1246 if app.selected_index >= app.envelopes.len()
1247 && app.selected_index > 0
1248 {
1249 app.selected_index -= 1;
1250 }
1251 if app.viewing_envelope.as_ref().map(|e| &e.id)
1253 == Some(&id)
1254 {
1255 app.viewing_envelope = None;
1256 app.body_view_state = app::BodyViewState::Empty { preview: None };
1257 app.layout_mode = app::LayoutMode::TwoPane;
1258 app.active_pane = app::ActivePane::MailList;
1259 }
1260 if show_completion_status {
1261 app.status_message = Some("Done".into());
1262 }
1263 app.pending_subscriptions_refresh = true;
1264 }
1265 app::MutationEffect::RemoveFromListMany(ids) => {
1266 app.envelopes.retain(|e| !ids.iter().any(|id| id == &e.id));
1267 app.all_envelopes
1268 .retain(|e| !ids.iter().any(|id| id == &e.id));
1269 if let Some(viewing_id) =
1270 app.viewing_envelope.as_ref().map(|e| e.id.clone())
1271 {
1272 if ids.iter().any(|id| id == &viewing_id) {
1273 app.viewing_envelope = None;
1274 app.body_view_state =
1275 app::BodyViewState::Empty { preview: None };
1276 app.layout_mode = app::LayoutMode::TwoPane;
1277 app.active_pane = app::ActivePane::MailList;
1278 }
1279 }
1280 if app.selected_index >= app.envelopes.len()
1281 && app.selected_index > 0
1282 {
1283 app.selected_index = app.envelopes.len() - 1;
1284 }
1285 if show_completion_status {
1286 app.status_message = Some("Done".into());
1287 }
1288 app.pending_subscriptions_refresh = true;
1289 }
1290 app::MutationEffect::UpdateFlags { message_id, flags } => {
1291 app.apply_local_flags(&message_id, flags);
1292 if show_completion_status {
1293 app.status_message = Some("Done".into());
1294 }
1295 }
1296 app::MutationEffect::UpdateFlagsMany { updates } => {
1297 app.apply_local_flags_many(&updates);
1298 if show_completion_status {
1299 app.status_message = Some("Done".into());
1300 }
1301 }
1302 app::MutationEffect::RefreshList => {
1303 if let Some(label_id) = app.active_label.clone() {
1304 app.pending_label_fetch = Some(label_id);
1305 }
1306 app.pending_subscriptions_refresh = true;
1307 if show_completion_status {
1308 app.status_message = Some("Synced".into());
1309 }
1310 }
1311 app::MutationEffect::ModifyLabels {
1312 message_ids,
1313 add,
1314 remove,
1315 status,
1316 } => {
1317 app.apply_local_label_refs(&message_ids, &add, &remove);
1318 if show_completion_status {
1319 app.status_message = Some(status);
1320 }
1321 }
1322 app::MutationEffect::StatusOnly(msg) => {
1323 if show_completion_status {
1324 app.status_message = Some(msg);
1325 }
1326 }
1327 }
1328 }
1329 AsyncResult::MutationResult(Err(e)) => {
1330 app.finish_pending_mutation();
1331 app.refresh_mailbox_after_mutation_failure();
1332 app.show_mutation_failure(&e);
1333 }
1334 AsyncResult::ComposeReady(Ok(data)) => {
1335 ratatui::restore();
1337 let editor = mxr_compose::editor::resolve_editor(None);
1338 let status = std::process::Command::new(&editor)
1339 .arg(format!("+{}", data.cursor_line))
1340 .arg(&data.draft_path)
1341 .status();
1342 terminal = ratatui::init();
1343 match status {
1344 Ok(s) if s.success() => {
1345 match pending_send_from_edited_draft(&data) {
1346 Ok(Some(pending)) => {
1347 app.pending_send_confirm = Some(pending);
1348 }
1349 Ok(None) => {}
1350 Err(message) => {
1351 app.status_message = Some(message);
1352 }
1353 }
1354 }
1355 Ok(_) => {
1356 app.status_message = Some("Draft discarded".into());
1358 let _ = std::fs::remove_file(&data.draft_path);
1359 }
1360 Err(e) => {
1361 app.status_message =
1362 Some(format!("Failed to launch editor: {e}"));
1363 }
1364 }
1365 }
1366 AsyncResult::ComposeReady(Err(e)) => {
1367 app.status_message = Some(format!("Compose error: {e}"));
1368 }
1369 AsyncResult::ExportResult(Ok(msg)) => {
1370 app.status_message = Some(msg);
1371 }
1372 AsyncResult::ExportResult(Err(e)) => {
1373 app.status_message = Some(format!("Export failed: {e}"));
1374 }
1375 AsyncResult::Unsubscribe(Ok(result)) => {
1376 if !result.archived_ids.is_empty() {
1377 app.envelopes
1378 .retain(|e| !result.archived_ids.iter().any(|id| id == &e.id));
1379 app.all_envelopes.retain(|e| {
1380 !result.archived_ids.iter().any(|id| id == &e.id)
1381 });
1382 app.search_page.results.retain(|e| {
1383 !result.archived_ids.iter().any(|id| id == &e.id)
1384 });
1385 if let Some(viewing_id) =
1386 app.viewing_envelope.as_ref().map(|e| e.id.clone())
1387 {
1388 if result.archived_ids.iter().any(|id| id == &viewing_id) {
1389 app.viewing_envelope = None;
1390 app.body_view_state =
1391 app::BodyViewState::Empty { preview: None };
1392 app.layout_mode = app::LayoutMode::TwoPane;
1393 app.active_pane = app::ActivePane::MailList;
1394 }
1395 }
1396 if app.selected_index >= app.envelopes.len() && app.selected_index > 0 {
1397 app.selected_index = app.envelopes.len() - 1;
1398 }
1399 }
1400 app.status_message = Some(result.message);
1401 app.pending_subscriptions_refresh = true;
1402 }
1403 AsyncResult::Unsubscribe(Err(e)) => {
1404 app.status_message = Some(format!("Unsubscribe failed: {e}"));
1405 }
1406 AsyncResult::Subscriptions(Ok(subscriptions)) => {
1407 app.set_subscriptions(subscriptions);
1408 }
1409 AsyncResult::Subscriptions(Err(e)) => {
1410 app.status_message = Some(format!("Subscriptions error: {e}"));
1411 }
1412 AsyncResult::DaemonEvent(event) => handle_daemon_event(&mut app, event),
1413 }
1414 }
1415 }
1416 _ = tokio::time::sleep(timeout) => {
1417 app.tick();
1418 }
1419 }
1420
1421 if app.should_quit {
1422 break;
1423 }
1424 }
1425
1426 ratatui::restore();
1427 Ok(())
1428}
1429
1430enum AsyncResult {
1431 Search(Result<SearchResultData, MxrError>),
1432 Rules(Result<Vec<serde_json::Value>, MxrError>),
1433 RuleDetail(Result<serde_json::Value, MxrError>),
1434 RuleHistory(Result<Vec<serde_json::Value>, MxrError>),
1435 RuleDryRun(Result<Vec<serde_json::Value>, MxrError>),
1436 RuleForm(Result<mxr_protocol::RuleFormData, MxrError>),
1437 RuleDeleted(Result<(), MxrError>),
1438 RuleUpsert(Result<serde_json::Value, MxrError>),
1439 Diagnostics(Box<Result<Response, MxrError>>),
1440 Status(Result<StatusSnapshot, MxrError>),
1441 Accounts(Result<Vec<mxr_protocol::AccountSummaryData>, MxrError>),
1442 Labels(Result<Vec<mxr_core::Label>, MxrError>),
1443 AllEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1444 Subscriptions(Result<Vec<mxr_core::types::SubscriptionSummary>, MxrError>),
1445 AccountOperation(Result<mxr_protocol::AccountOperationResult, MxrError>),
1446 BugReport(Result<String, MxrError>),
1447 AttachmentFile {
1448 operation: AttachmentOperation,
1449 result: Result<mxr_protocol::AttachmentFile, MxrError>,
1450 },
1451 LabelEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1452 Bodies {
1453 requested: Vec<mxr_core::MessageId>,
1454 result: Result<Vec<mxr_core::MessageBody>, MxrError>,
1455 },
1456 Thread {
1457 thread_id: mxr_core::ThreadId,
1458 result: Result<(mxr_core::Thread, Vec<mxr_core::Envelope>), MxrError>,
1459 },
1460 MutationResult(Result<app::MutationEffect, MxrError>),
1461 ComposeReady(Result<ComposeReadyData, MxrError>),
1462 ExportResult(Result<String, MxrError>),
1463 Unsubscribe(Result<UnsubscribeResultData, MxrError>),
1464 DaemonEvent(DaemonEvent),
1465}
1466
1467struct ComposeReadyData {
1468 draft_path: std::path::PathBuf,
1469 cursor_line: usize,
1470 initial_content: String,
1471}
1472
1473struct SearchResultData {
1474 envelopes: Vec<mxr_core::types::Envelope>,
1475 scores: std::collections::HashMap<mxr_core::MessageId, f32>,
1476}
1477
1478struct StatusSnapshot {
1479 uptime_secs: u64,
1480 daemon_pid: Option<u32>,
1481 accounts: Vec<String>,
1482 total_messages: u32,
1483 sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
1484}
1485
1486struct UnsubscribeResultData {
1487 archived_ids: Vec<mxr_core::MessageId>,
1488 message: String,
1489}
1490
1491async fn handle_compose_action(
1492 bg: &mpsc::UnboundedSender<IpcRequest>,
1493 action: ComposeAction,
1494) -> Result<ComposeReadyData, MxrError> {
1495 let from = get_account_email(bg).await?;
1496
1497 let kind = match action {
1498 ComposeAction::EditDraft(path) => {
1499 let cursor_line = 1;
1501 return Ok(ComposeReadyData {
1502 draft_path: path.clone(),
1503 cursor_line,
1504 initial_content: std::fs::read_to_string(&path)
1505 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1506 });
1507 }
1508 ComposeAction::New => mxr_compose::ComposeKind::New,
1509 ComposeAction::NewWithTo(to) => mxr_compose::ComposeKind::NewWithTo { to },
1510 ComposeAction::Reply { message_id } => {
1511 let resp = ipc_call(
1512 bg,
1513 Request::PrepareReply {
1514 message_id,
1515 reply_all: false,
1516 },
1517 )
1518 .await?;
1519 match resp {
1520 Response::Ok {
1521 data: ResponseData::ReplyContext { context },
1522 } => mxr_compose::ComposeKind::Reply {
1523 in_reply_to: context.in_reply_to,
1524 references: context.references,
1525 to: context.reply_to,
1526 cc: context.cc,
1527 subject: context.subject,
1528 thread_context: context.thread_context,
1529 },
1530 Response::Error { message } => return Err(MxrError::Ipc(message)),
1531 _ => return Err(MxrError::Ipc("unexpected response".into())),
1532 }
1533 }
1534 ComposeAction::ReplyAll { message_id } => {
1535 let resp = ipc_call(
1536 bg,
1537 Request::PrepareReply {
1538 message_id,
1539 reply_all: true,
1540 },
1541 )
1542 .await?;
1543 match resp {
1544 Response::Ok {
1545 data: ResponseData::ReplyContext { context },
1546 } => mxr_compose::ComposeKind::Reply {
1547 in_reply_to: context.in_reply_to,
1548 references: context.references,
1549 to: context.reply_to,
1550 cc: context.cc,
1551 subject: context.subject,
1552 thread_context: context.thread_context,
1553 },
1554 Response::Error { message } => return Err(MxrError::Ipc(message)),
1555 _ => return Err(MxrError::Ipc("unexpected response".into())),
1556 }
1557 }
1558 ComposeAction::Forward { message_id } => {
1559 let resp = ipc_call(bg, Request::PrepareForward { message_id }).await?;
1560 match resp {
1561 Response::Ok {
1562 data: ResponseData::ForwardContext { context },
1563 } => mxr_compose::ComposeKind::Forward {
1564 subject: context.subject,
1565 original_context: context.forwarded_content,
1566 },
1567 Response::Error { message } => return Err(MxrError::Ipc(message)),
1568 _ => return Err(MxrError::Ipc("unexpected response".into())),
1569 }
1570 }
1571 };
1572
1573 let (path, cursor_line) =
1574 mxr_compose::create_draft_file(kind, &from).map_err(|e| MxrError::Ipc(e.to_string()))?;
1575
1576 Ok(ComposeReadyData {
1577 draft_path: path.clone(),
1578 cursor_line,
1579 initial_content: std::fs::read_to_string(&path)
1580 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1581 })
1582}
1583
1584async fn get_account_email(bg: &mpsc::UnboundedSender<IpcRequest>) -> Result<String, MxrError> {
1585 let resp = ipc_call(bg, Request::ListAccounts).await?;
1586 match resp {
1587 Response::Ok {
1588 data: ResponseData::Accounts { mut accounts },
1589 } => {
1590 if let Some(index) = accounts.iter().position(|account| account.is_default) {
1591 Ok(accounts.remove(index).email)
1592 } else {
1593 accounts
1594 .into_iter()
1595 .next()
1596 .map(|account| account.email)
1597 .ok_or_else(|| MxrError::Ipc("No runtime account configured".into()))
1598 }
1599 }
1600 Response::Error { message } => Err(MxrError::Ipc(message)),
1601 _ => Err(MxrError::Ipc("Unexpected account response".into())),
1602 }
1603}
1604
1605fn pending_send_from_edited_draft(data: &ComposeReadyData) -> Result<Option<PendingSend>, String> {
1606 let content = std::fs::read_to_string(&data.draft_path)
1607 .map_err(|e| format!("Failed to read draft: {e}"))?;
1608 let unchanged = content == data.initial_content;
1609
1610 let (fm, body) = mxr_compose::frontmatter::parse_compose_file(&content)
1611 .map_err(|e| format!("Parse error: {e}"))?;
1612 let issues = mxr_compose::validate_draft(&fm, &body);
1613 let has_errors = issues.iter().any(|issue| issue.is_error());
1614 if has_errors {
1615 let msgs: Vec<String> = issues.iter().map(|issue| issue.to_string()).collect();
1616 return Err(format!("Draft errors: {}", msgs.join("; ")));
1617 }
1618
1619 Ok(Some(PendingSend {
1620 fm,
1621 body,
1622 draft_path: data.draft_path.clone(),
1623 allow_send: !unchanged,
1624 }))
1625}
1626
1627fn daemon_socket_path() -> std::path::PathBuf {
1628 config_socket_path()
1629}
1630
1631async fn request_account_operation(
1632 bg: &mpsc::UnboundedSender<IpcRequest>,
1633 request: Request,
1634) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1635 let resp = ipc_call(bg, request).await;
1636 match resp {
1637 Ok(Response::Ok {
1638 data: ResponseData::AccountOperation { result },
1639 }) => Ok(result),
1640 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1641 Err(e) => Err(e),
1642 _ => Err(MxrError::Ipc("unexpected response".into())),
1643 }
1644}
1645
1646async fn run_account_save_workflow(
1647 bg: &mpsc::UnboundedSender<IpcRequest>,
1648 account: mxr_protocol::AccountConfigData,
1649) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1650 let mut result = if matches!(
1651 account.sync,
1652 Some(mxr_protocol::AccountSyncConfigData::Gmail { .. })
1653 ) {
1654 request_account_operation(
1655 bg,
1656 Request::AuthorizeAccountConfig {
1657 account: account.clone(),
1658 reauthorize: false,
1659 },
1660 )
1661 .await?
1662 } else {
1663 empty_account_operation_result()
1664 };
1665
1666 if result.auth.as_ref().is_some_and(|step| !step.ok) {
1667 return Ok(result);
1668 }
1669
1670 let save_result = request_account_operation(
1671 bg,
1672 Request::UpsertAccountConfig {
1673 account: account.clone(),
1674 },
1675 )
1676 .await?;
1677 merge_account_operation_result(&mut result, save_result);
1678
1679 if result.save.as_ref().is_some_and(|step| !step.ok) {
1680 return Ok(result);
1681 }
1682
1683 let test_result = request_account_operation(bg, Request::TestAccountConfig { account }).await?;
1684 merge_account_operation_result(&mut result, test_result);
1685
1686 Ok(result)
1687}
1688
1689fn empty_account_operation_result() -> mxr_protocol::AccountOperationResult {
1690 mxr_protocol::AccountOperationResult {
1691 ok: true,
1692 summary: String::new(),
1693 save: None,
1694 auth: None,
1695 sync: None,
1696 send: None,
1697 }
1698}
1699
1700fn merge_account_operation_result(
1701 base: &mut mxr_protocol::AccountOperationResult,
1702 next: mxr_protocol::AccountOperationResult,
1703) {
1704 base.ok &= next.ok;
1705 if !next.summary.is_empty() {
1706 if base.summary.is_empty() {
1707 base.summary = next.summary;
1708 } else {
1709 base.summary = format!("{} | {}", base.summary, next.summary);
1710 }
1711 }
1712 if next.save.is_some() {
1713 base.save = next.save;
1714 }
1715 if next.auth.is_some() {
1716 base.auth = next.auth;
1717 }
1718 if next.sync.is_some() {
1719 base.sync = next.sync;
1720 }
1721 if next.send.is_some() {
1722 base.send = next.send;
1723 }
1724}
1725
1726fn handle_daemon_event(app: &mut App, event: DaemonEvent) {
1727 match event {
1728 DaemonEvent::SyncCompleted {
1729 messages_synced, ..
1730 } => {
1731 app.pending_labels_refresh = true;
1732 app.pending_all_envelopes_refresh = true;
1733 app.pending_subscriptions_refresh = true;
1734 app.pending_status_refresh = true;
1735 if let Some(label_id) = app.active_label.clone() {
1736 app.pending_label_fetch = Some(label_id);
1737 }
1738 if messages_synced > 0 {
1739 app.status_message = Some(format!("Synced {messages_synced} messages"));
1740 }
1741 }
1742 DaemonEvent::LabelCountsUpdated { counts } => {
1743 for count in &counts {
1744 if let Some(label) = app
1745 .labels
1746 .iter_mut()
1747 .find(|label| label.id == count.label_id)
1748 {
1749 label.unread_count = count.unread_count;
1750 label.total_count = count.total_count;
1751 }
1752 }
1753 }
1754 _ => {}
1755 }
1756}
1757
1758fn apply_all_envelopes_refresh(app: &mut App, envelopes: Vec<mxr_core::Envelope>) {
1759 app.all_envelopes = envelopes;
1760 if app.active_label.is_none() && !app.search_active {
1761 app.envelopes = app
1762 .all_envelopes
1763 .iter()
1764 .filter(|envelope| !envelope.flags.contains(mxr_core::MessageFlags::TRASH))
1765 .cloned()
1766 .collect();
1767 if app.mailbox_view == app::MailboxView::Messages {
1768 let selected_id = app.selected_mail_row().map(|row| row.representative.id);
1769 restore_mail_list_selection(app, selected_id);
1770 } else {
1771 app.selected_index = app
1772 .selected_index
1773 .min(app.subscriptions_page.entries.len().saturating_sub(1));
1774 }
1775 app.queue_body_window();
1776 }
1777}
1778
1779fn restore_mail_list_selection(app: &mut App, selected_id: Option<mxr_core::MessageId>) {
1780 let row_count = app.mail_list_rows().len();
1781 if row_count == 0 {
1782 app.selected_index = 0;
1783 app.scroll_offset = 0;
1784 return;
1785 }
1786
1787 if let Some(id) = selected_id {
1788 if let Some(position) = app
1789 .mail_list_rows()
1790 .iter()
1791 .position(|row| row.representative.id == id)
1792 {
1793 app.selected_index = position;
1794 } else {
1795 app.selected_index = app.selected_index.min(row_count.saturating_sub(1));
1796 }
1797 } else {
1798 app.selected_index = 0;
1799 }
1800
1801 let visible_height = app.visible_height.max(1);
1802 if app.selected_index < app.scroll_offset {
1803 app.scroll_offset = app.selected_index;
1804 } else if app.selected_index >= app.scroll_offset + visible_height {
1805 app.scroll_offset = app.selected_index + 1 - visible_height;
1806 }
1807}
1808
1809async fn load_accounts_page_accounts(
1810 bg: &mpsc::UnboundedSender<IpcRequest>,
1811) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1812 match ipc_call(bg, Request::ListAccounts).await {
1813 Ok(Response::Ok {
1814 data: ResponseData::Accounts { accounts },
1815 }) if !accounts.is_empty() => Ok(accounts),
1816 Ok(Response::Ok {
1817 data: ResponseData::Accounts { .. },
1818 })
1819 | Ok(Response::Error { .. })
1820 | Err(_) => load_config_account_summaries(bg).await,
1821 Ok(_) => Err(MxrError::Ipc("unexpected response".into())),
1822 }
1823}
1824
1825async fn load_config_account_summaries(
1826 bg: &mpsc::UnboundedSender<IpcRequest>,
1827) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1828 let resp = ipc_call(bg, Request::ListAccountsConfig).await?;
1829 match resp {
1830 Response::Ok {
1831 data: ResponseData::AccountsConfig { accounts },
1832 } => Ok(accounts
1833 .into_iter()
1834 .map(account_config_to_summary)
1835 .collect()),
1836 Response::Error { message } => Err(MxrError::Ipc(message)),
1837 _ => Err(MxrError::Ipc("unexpected response".into())),
1838 }
1839}
1840
1841fn account_config_to_summary(
1842 account: mxr_protocol::AccountConfigData,
1843) -> mxr_protocol::AccountSummaryData {
1844 let provider_kind = account
1845 .sync
1846 .as_ref()
1847 .map(account_sync_kind_label)
1848 .or_else(|| account.send.as_ref().map(account_send_kind_label))
1849 .unwrap_or_else(|| "unknown".to_string());
1850 let account_id = mxr_core::AccountId::from_provider_id(&provider_kind, &account.email);
1851
1852 mxr_protocol::AccountSummaryData {
1853 account_id,
1854 key: Some(account.key),
1855 name: account.name,
1856 email: account.email,
1857 provider_kind,
1858 sync_kind: account.sync.as_ref().map(account_sync_kind_label),
1859 send_kind: account.send.as_ref().map(account_send_kind_label),
1860 enabled: true,
1861 is_default: account.is_default,
1862 source: mxr_protocol::AccountSourceData::Config,
1863 editable: mxr_protocol::AccountEditModeData::Full,
1864 sync: account.sync,
1865 send: account.send,
1866 }
1867}
1868
1869fn account_sync_kind_label(sync: &mxr_protocol::AccountSyncConfigData) -> String {
1870 match sync {
1871 mxr_protocol::AccountSyncConfigData::Gmail { .. } => "gmail".to_string(),
1872 mxr_protocol::AccountSyncConfigData::Imap { .. } => "imap".to_string(),
1873 }
1874}
1875
1876fn account_send_kind_label(send: &mxr_protocol::AccountSendConfigData) -> String {
1877 match send {
1878 mxr_protocol::AccountSendConfigData::Gmail => "gmail".to_string(),
1879 mxr_protocol::AccountSendConfigData::Smtp { .. } => "smtp".to_string(),
1880 }
1881}
1882
1883#[cfg(test)]
1884mod tests {
1885 use super::action::Action;
1886 use super::app::{
1887 ActivePane, App, BodySource, BodyViewState, LayoutMode, MutationEffect, Screen, SidebarItem,
1888 };
1889 use super::input::InputHandler;
1890 use super::ui::command_palette::default_commands;
1891 use super::ui::command_palette::CommandPalette;
1892 use super::ui::search_bar::SearchBar;
1893 use super::ui::status_bar;
1894 use super::{
1895 apply_all_envelopes_refresh, handle_daemon_event, pending_send_from_edited_draft,
1896 ComposeReadyData, PendingSend,
1897 };
1898 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1899 use mxr_config::RenderConfig;
1900 use mxr_core::id::*;
1901 use mxr_core::types::*;
1902 use mxr_core::MxrError;
1903 use mxr_protocol::{DaemonEvent, MutationCommand, Request};
1904
1905 fn make_test_envelopes(count: usize) -> Vec<Envelope> {
1906 (0..count)
1907 .map(|i| Envelope {
1908 id: MessageId::new(),
1909 account_id: AccountId::new(),
1910 provider_id: format!("fake-{}", i),
1911 thread_id: ThreadId::new(),
1912 message_id_header: None,
1913 in_reply_to: None,
1914 references: vec![],
1915 from: Address {
1916 name: Some(format!("User {}", i)),
1917 email: format!("user{}@example.com", i),
1918 },
1919 to: vec![],
1920 cc: vec![],
1921 bcc: vec![],
1922 subject: format!("Subject {}", i),
1923 date: chrono::Utc::now(),
1924 flags: if i % 2 == 0 {
1925 MessageFlags::READ
1926 } else {
1927 MessageFlags::empty()
1928 },
1929 snippet: format!("Snippet {}", i),
1930 has_attachments: false,
1931 size_bytes: 1000,
1932 unsubscribe: UnsubscribeMethod::None,
1933 label_provider_ids: vec![],
1934 })
1935 .collect()
1936 }
1937
1938 fn make_unsubscribe_envelope(
1939 account_id: AccountId,
1940 sender_email: &str,
1941 unsubscribe: UnsubscribeMethod,
1942 ) -> Envelope {
1943 Envelope {
1944 id: MessageId::new(),
1945 account_id,
1946 provider_id: "unsub-fixture".into(),
1947 thread_id: ThreadId::new(),
1948 message_id_header: None,
1949 in_reply_to: None,
1950 references: vec![],
1951 from: Address {
1952 name: Some("Newsletter".into()),
1953 email: sender_email.into(),
1954 },
1955 to: vec![],
1956 cc: vec![],
1957 bcc: vec![],
1958 subject: "Newsletter".into(),
1959 date: chrono::Utc::now(),
1960 flags: MessageFlags::empty(),
1961 snippet: "newsletter".into(),
1962 has_attachments: false,
1963 size_bytes: 42,
1964 unsubscribe,
1965 label_provider_ids: vec![],
1966 }
1967 }
1968
1969 #[test]
1970 fn input_j_moves_down() {
1971 let mut h = InputHandler::new();
1972 assert_eq!(
1973 h.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
1974 Some(Action::MoveDown)
1975 );
1976 }
1977
1978 #[test]
1979 fn input_k_moves_up() {
1980 let mut h = InputHandler::new();
1981 assert_eq!(
1982 h.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
1983 Some(Action::MoveUp)
1984 );
1985 }
1986
1987 #[test]
1988 fn input_gg_jumps_top() {
1989 let mut h = InputHandler::new();
1990 assert_eq!(
1991 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
1992 None
1993 );
1994 assert_eq!(
1995 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
1996 Some(Action::JumpTop)
1997 );
1998 }
1999
2000 #[test]
2001 fn input_zz_centers() {
2002 let mut h = InputHandler::new();
2003 assert_eq!(
2004 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2005 None
2006 );
2007 assert_eq!(
2008 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2009 Some(Action::CenterCurrent)
2010 );
2011 }
2012
2013 #[test]
2014 fn input_enter_opens() {
2015 let mut h = InputHandler::new();
2016 assert_eq!(
2017 h.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2018 Some(Action::OpenSelected)
2019 );
2020 }
2021
2022 #[test]
2023 fn input_o_opens() {
2024 let mut h = InputHandler::new();
2025 assert_eq!(
2026 h.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
2027 Some(Action::OpenSelected)
2028 );
2029 }
2030
2031 #[test]
2032 fn input_escape_back() {
2033 let mut h = InputHandler::new();
2034 assert_eq!(
2035 h.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
2036 Some(Action::Back)
2037 );
2038 }
2039
2040 #[test]
2041 fn input_q_quits() {
2042 let mut h = InputHandler::new();
2043 assert_eq!(
2044 h.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
2045 Some(Action::QuitView)
2046 );
2047 }
2048
2049 #[test]
2050 fn input_hml_viewport() {
2051 let mut h = InputHandler::new();
2052 assert_eq!(
2053 h.handle_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT)),
2054 Some(Action::ViewportTop)
2055 );
2056 assert_eq!(
2057 h.handle_key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::SHIFT)),
2058 Some(Action::ViewportMiddle)
2059 );
2060 assert_eq!(
2061 h.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT)),
2062 Some(Action::ViewportBottom)
2063 );
2064 }
2065
2066 #[test]
2067 fn input_ctrl_du_page() {
2068 let mut h = InputHandler::new();
2069 assert_eq!(
2070 h.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
2071 Some(Action::PageDown)
2072 );
2073 assert_eq!(
2074 h.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
2075 Some(Action::PageUp)
2076 );
2077 }
2078
2079 #[test]
2080 fn app_move_down() {
2081 let mut app = App::new();
2082 app.envelopes = make_test_envelopes(5);
2083 app.apply(Action::MoveDown);
2084 assert_eq!(app.selected_index, 1);
2085 }
2086
2087 #[test]
2088 fn app_move_up_at_zero() {
2089 let mut app = App::new();
2090 app.envelopes = make_test_envelopes(5);
2091 app.apply(Action::MoveUp);
2092 assert_eq!(app.selected_index, 0);
2093 }
2094
2095 #[test]
2096 fn app_jump_top() {
2097 let mut app = App::new();
2098 app.envelopes = make_test_envelopes(10);
2099 app.selected_index = 5;
2100 app.apply(Action::JumpTop);
2101 assert_eq!(app.selected_index, 0);
2102 }
2103
2104 #[test]
2105 fn app_switch_pane() {
2106 let mut app = App::new();
2107 assert_eq!(app.active_pane, ActivePane::MailList);
2108 app.apply(Action::SwitchPane);
2109 assert_eq!(app.active_pane, ActivePane::Sidebar);
2110 app.apply(Action::SwitchPane);
2111 assert_eq!(app.active_pane, ActivePane::MailList);
2112 }
2113
2114 #[test]
2115 fn app_quit() {
2116 let mut app = App::new();
2117 app.apply(Action::QuitView);
2118 assert!(app.should_quit);
2119 }
2120
2121 #[test]
2122 fn app_new_uses_default_reader_mode() {
2123 let app = App::new();
2124 assert!(app.reader_mode);
2125 }
2126
2127 #[test]
2128 fn app_from_render_config_respects_reader_mode() {
2129 let config = RenderConfig {
2130 reader_mode: false,
2131 ..Default::default()
2132 };
2133 let app = App::from_render_config(&config);
2134 assert!(!app.reader_mode);
2135 }
2136
2137 #[test]
2138 fn app_move_down_bounds() {
2139 let mut app = App::new();
2140 app.envelopes = make_test_envelopes(3);
2141 app.apply(Action::MoveDown);
2142 app.apply(Action::MoveDown);
2143 app.apply(Action::MoveDown);
2144 assert_eq!(app.selected_index, 2);
2145 }
2146
2147 #[test]
2148 fn layout_mode_switching() {
2149 let mut app = App::new();
2150 app.envelopes = make_test_envelopes(3);
2151 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2152 app.apply(Action::OpenMessageView);
2153 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2154 app.apply(Action::CloseMessageView);
2155 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2156 }
2157
2158 #[test]
2159 fn command_palette_toggle() {
2160 let mut p = CommandPalette::default();
2161 assert!(!p.visible);
2162 p.toggle();
2163 assert!(p.visible);
2164 p.toggle();
2165 assert!(!p.visible);
2166 }
2167
2168 #[test]
2169 fn command_palette_fuzzy_filter() {
2170 let mut p = CommandPalette::default();
2171 p.toggle();
2172 p.on_char('i');
2173 p.on_char('n');
2174 p.on_char('b');
2175 let labels: Vec<&str> = p
2176 .filtered
2177 .iter()
2178 .map(|&i| p.commands[i].label.as_str())
2179 .collect();
2180 assert!(labels.contains(&"Go to Inbox"));
2181 }
2182
2183 #[test]
2184 fn unsubscribe_opens_confirm_modal_and_scopes_archive_to_sender_and_account() {
2185 let mut app = App::new();
2186 let account_id = AccountId::new();
2187 let other_account_id = AccountId::new();
2188 let target = make_unsubscribe_envelope(
2189 account_id.clone(),
2190 "news@example.com",
2191 UnsubscribeMethod::HttpLink {
2192 url: "https://example.com/unsub".into(),
2193 },
2194 );
2195 let same_sender_same_account = make_unsubscribe_envelope(
2196 account_id.clone(),
2197 "news@example.com",
2198 UnsubscribeMethod::None,
2199 );
2200 let same_sender_other_account = make_unsubscribe_envelope(
2201 other_account_id,
2202 "news@example.com",
2203 UnsubscribeMethod::None,
2204 );
2205 let different_sender_same_account =
2206 make_unsubscribe_envelope(account_id, "other@example.com", UnsubscribeMethod::None);
2207
2208 app.envelopes = vec![target.clone()];
2209 app.all_envelopes = vec![
2210 target.clone(),
2211 same_sender_same_account.clone(),
2212 same_sender_other_account,
2213 different_sender_same_account,
2214 ];
2215
2216 app.apply(Action::Unsubscribe);
2217
2218 let pending = app
2219 .pending_unsubscribe_confirm
2220 .as_ref()
2221 .expect("unsubscribe modal should open");
2222 assert_eq!(pending.sender_email, "news@example.com");
2223 assert_eq!(pending.method_label, "browser link");
2224 assert_eq!(pending.archive_message_ids.len(), 2);
2225 assert!(pending.archive_message_ids.contains(&target.id));
2226 assert!(pending
2227 .archive_message_ids
2228 .contains(&same_sender_same_account.id));
2229 }
2230
2231 #[test]
2232 fn unsubscribe_without_method_sets_status_error() {
2233 let mut app = App::new();
2234 let env = make_unsubscribe_envelope(
2235 AccountId::new(),
2236 "news@example.com",
2237 UnsubscribeMethod::None,
2238 );
2239 app.envelopes = vec![env];
2240
2241 app.apply(Action::Unsubscribe);
2242
2243 assert!(app.pending_unsubscribe_confirm.is_none());
2244 assert_eq!(
2245 app.status_message.as_deref(),
2246 Some("No unsubscribe option found for this message")
2247 );
2248 }
2249
2250 #[test]
2251 fn unsubscribe_confirm_archive_populates_pending_action() {
2252 let mut app = App::new();
2253 let env = make_unsubscribe_envelope(
2254 AccountId::new(),
2255 "news@example.com",
2256 UnsubscribeMethod::OneClick {
2257 url: "https://example.com/one-click".into(),
2258 },
2259 );
2260 app.envelopes = vec![env.clone()];
2261 app.all_envelopes = vec![env.clone()];
2262 app.apply(Action::Unsubscribe);
2263 app.apply(Action::ConfirmUnsubscribeAndArchiveSender);
2264
2265 let pending = app
2266 .pending_unsubscribe_action
2267 .as_ref()
2268 .expect("unsubscribe action should be queued");
2269 assert_eq!(pending.message_id, env.id);
2270 assert_eq!(pending.archive_message_ids.len(), 1);
2271 assert_eq!(pending.sender_email, "news@example.com");
2272 }
2273
2274 #[test]
2275 fn search_input_lifecycle() {
2276 let mut bar = SearchBar::default();
2277 bar.activate();
2278 assert!(bar.active);
2279 bar.on_char('h');
2280 bar.on_char('e');
2281 bar.on_char('l');
2282 bar.on_char('l');
2283 bar.on_char('o');
2284 assert_eq!(bar.query, "hello");
2285 let q = bar.submit();
2286 assert_eq!(q, "hello");
2287 assert!(!bar.active);
2288 }
2289
2290 #[test]
2291 fn search_bar_cycles_modes() {
2292 let mut bar = SearchBar::default();
2293 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2294 bar.cycle_mode();
2295 assert_eq!(bar.mode, mxr_core::SearchMode::Hybrid);
2296 bar.cycle_mode();
2297 assert_eq!(bar.mode, mxr_core::SearchMode::Semantic);
2298 bar.cycle_mode();
2299 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2300 }
2301
2302 #[test]
2303 fn reopening_active_search_preserves_query() {
2304 let mut app = App::new();
2305 app.search_active = true;
2306 app.search_bar.query = "deploy".to_string();
2307 app.search_bar.cursor_pos = 0;
2308
2309 app.apply(Action::OpenSearch);
2310
2311 assert!(app.search_bar.active);
2312 assert_eq!(app.search_bar.query, "deploy");
2313 assert_eq!(app.search_bar.cursor_pos, "deploy".len());
2314 }
2315
2316 #[test]
2317 fn g_prefix_navigation() {
2318 let mut h = InputHandler::new();
2319 assert_eq!(
2320 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2321 None
2322 );
2323 assert_eq!(
2324 h.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)),
2325 Some(Action::GoToInbox)
2326 );
2327 assert_eq!(
2328 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2329 None
2330 );
2331 assert_eq!(
2332 h.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)),
2333 Some(Action::GoToStarred)
2334 );
2335 }
2336
2337 #[test]
2338 fn status_bar_sync_formats() {
2339 assert_eq!(
2340 status_bar::format_sync_status(12, Some("synced 2m ago")),
2341 "[INBOX] 12 unread | synced 2m ago"
2342 );
2343 assert_eq!(
2344 status_bar::format_sync_status(0, None),
2345 "[INBOX] 0 unread | not synced"
2346 );
2347 }
2348
2349 fn make_test_labels() -> Vec<Label> {
2350 vec![
2351 Label {
2352 id: LabelId::from_provider_id("test", "INBOX"),
2353 account_id: AccountId::new(),
2354 name: "INBOX".to_string(),
2355 kind: LabelKind::System,
2356 color: None,
2357 provider_id: "INBOX".to_string(),
2358 unread_count: 3,
2359 total_count: 10,
2360 },
2361 Label {
2362 id: LabelId::from_provider_id("test", "STARRED"),
2363 account_id: AccountId::new(),
2364 name: "STARRED".to_string(),
2365 kind: LabelKind::System,
2366 color: None,
2367 provider_id: "STARRED".to_string(),
2368 unread_count: 0,
2369 total_count: 2,
2370 },
2371 Label {
2372 id: LabelId::from_provider_id("test", "SENT"),
2373 account_id: AccountId::new(),
2374 name: "SENT".to_string(),
2375 kind: LabelKind::System,
2376 color: None,
2377 provider_id: "SENT".to_string(),
2378 unread_count: 0,
2379 total_count: 5,
2380 },
2381 Label {
2382 id: LabelId::from_provider_id("test", "DRAFT"),
2383 account_id: AccountId::new(),
2384 name: "DRAFT".to_string(),
2385 kind: LabelKind::System,
2386 color: None,
2387 provider_id: "DRAFT".to_string(),
2388 unread_count: 0,
2389 total_count: 0,
2390 },
2391 Label {
2392 id: LabelId::from_provider_id("test", "ARCHIVE"),
2393 account_id: AccountId::new(),
2394 name: "ARCHIVE".to_string(),
2395 kind: LabelKind::System,
2396 color: None,
2397 provider_id: "ARCHIVE".to_string(),
2398 unread_count: 0,
2399 total_count: 0,
2400 },
2401 Label {
2402 id: LabelId::from_provider_id("test", "SPAM"),
2403 account_id: AccountId::new(),
2404 name: "SPAM".to_string(),
2405 kind: LabelKind::System,
2406 color: None,
2407 provider_id: "SPAM".to_string(),
2408 unread_count: 0,
2409 total_count: 0,
2410 },
2411 Label {
2412 id: LabelId::from_provider_id("test", "TRASH"),
2413 account_id: AccountId::new(),
2414 name: "TRASH".to_string(),
2415 kind: LabelKind::System,
2416 color: None,
2417 provider_id: "TRASH".to_string(),
2418 unread_count: 0,
2419 total_count: 0,
2420 },
2421 Label {
2423 id: LabelId::from_provider_id("test", "CHAT"),
2424 account_id: AccountId::new(),
2425 name: "CHAT".to_string(),
2426 kind: LabelKind::System,
2427 color: None,
2428 provider_id: "CHAT".to_string(),
2429 unread_count: 0,
2430 total_count: 0,
2431 },
2432 Label {
2433 id: LabelId::from_provider_id("test", "IMPORTANT"),
2434 account_id: AccountId::new(),
2435 name: "IMPORTANT".to_string(),
2436 kind: LabelKind::System,
2437 color: None,
2438 provider_id: "IMPORTANT".to_string(),
2439 unread_count: 0,
2440 total_count: 5,
2441 },
2442 Label {
2444 id: LabelId::from_provider_id("test", "Work"),
2445 account_id: AccountId::new(),
2446 name: "Work".to_string(),
2447 kind: LabelKind::User,
2448 color: None,
2449 provider_id: "Label_1".to_string(),
2450 unread_count: 2,
2451 total_count: 10,
2452 },
2453 Label {
2454 id: LabelId::from_provider_id("test", "Personal"),
2455 account_id: AccountId::new(),
2456 name: "Personal".to_string(),
2457 kind: LabelKind::User,
2458 color: None,
2459 provider_id: "Label_2".to_string(),
2460 unread_count: 0,
2461 total_count: 3,
2462 },
2463 Label {
2465 id: LabelId::from_provider_id("test", "CATEGORY_UPDATES"),
2466 account_id: AccountId::new(),
2467 name: "CATEGORY_UPDATES".to_string(),
2468 kind: LabelKind::System,
2469 color: None,
2470 provider_id: "CATEGORY_UPDATES".to_string(),
2471 unread_count: 0,
2472 total_count: 50,
2473 },
2474 ]
2475 }
2476
2477 #[test]
2480 fn threepane_l_loads_new_message() {
2481 let mut app = App::new();
2482 app.envelopes = make_test_envelopes(5);
2483 app.all_envelopes = app.envelopes.clone();
2484 app.apply(Action::OpenSelected);
2486 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2487 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2488 app.active_pane = ActivePane::MailList;
2490 app.apply(Action::MoveDown);
2492 app.apply(Action::OpenSelected);
2494 let second_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2495 assert_ne!(
2496 first_id, second_id,
2497 "l should load the new message, not stay on old one"
2498 );
2499 assert_eq!(app.selected_index, 1);
2500 }
2501
2502 #[test]
2503 fn threepane_jk_auto_preview() {
2504 let mut app = App::new();
2505 app.envelopes = make_test_envelopes(5);
2506 app.all_envelopes = app.envelopes.clone();
2507 app.apply(Action::OpenSelected);
2509 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2510 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2511 app.active_pane = ActivePane::MailList;
2513 app.apply(Action::MoveDown);
2515 let preview_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2516 assert_ne!(first_id, preview_id, "j/k should auto-preview in ThreePane");
2517 }
2520
2521 #[test]
2522 fn twopane_jk_no_auto_preview() {
2523 let mut app = App::new();
2524 app.envelopes = make_test_envelopes(5);
2525 app.all_envelopes = app.envelopes.clone();
2526 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2528 app.apply(Action::MoveDown);
2529 assert!(
2530 app.viewing_envelope.is_none(),
2531 "j/k should not auto-preview in TwoPane"
2532 );
2533 }
2535
2536 #[test]
2539 fn back_in_message_view_closes_preview_pane() {
2540 let mut app = App::new();
2541 app.envelopes = make_test_envelopes(3);
2542 app.all_envelopes = app.envelopes.clone();
2543 app.apply(Action::OpenSelected);
2544 assert_eq!(app.active_pane, ActivePane::MessageView);
2545 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2546 app.apply(Action::Back);
2547 assert_eq!(app.active_pane, ActivePane::MailList);
2548 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2549 assert!(app.viewing_envelope.is_none());
2550 }
2551
2552 #[test]
2553 fn back_in_mail_list_clears_label_filter() {
2554 let mut app = App::new();
2555 app.envelopes = make_test_envelopes(5);
2556 app.all_envelopes = app.envelopes.clone();
2557 app.labels = make_test_labels();
2558 let inbox_id = app
2559 .labels
2560 .iter()
2561 .find(|l| l.name == "INBOX")
2562 .unwrap()
2563 .id
2564 .clone();
2565 app.active_label = Some(inbox_id);
2567 app.envelopes = vec![app.envelopes[0].clone()]; app.apply(Action::Back);
2570 assert!(app.active_label.is_none(), "Esc should clear label filter");
2571 assert_eq!(app.envelopes.len(), 5, "Should restore all envelopes");
2572 }
2573
2574 #[test]
2575 fn back_in_mail_list_closes_threepane_when_no_filter() {
2576 let mut app = App::new();
2577 app.envelopes = make_test_envelopes(3);
2578 app.all_envelopes = app.envelopes.clone();
2579 app.apply(Action::OpenSelected); app.active_pane = ActivePane::MailList; app.apply(Action::Back);
2583 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2584 }
2585
2586 #[test]
2589 fn sidebar_system_labels_before_user_labels() {
2590 let mut app = App::new();
2591 app.labels = make_test_labels();
2592 let ordered = app.ordered_visible_labels();
2593 let first_user_idx = ordered.iter().position(|l| l.kind == LabelKind::User);
2595 let last_system_idx = ordered.iter().rposition(|l| l.kind == LabelKind::System);
2596 if let (Some(first_user), Some(last_system)) = (first_user_idx, last_system_idx) {
2597 assert!(
2598 last_system < first_user,
2599 "All system labels should come before user labels"
2600 );
2601 }
2602 }
2603
2604 #[test]
2605 fn sidebar_system_labels_in_correct_order() {
2606 let mut app = App::new();
2607 app.labels = make_test_labels();
2608 let ordered = app.ordered_visible_labels();
2609 let system_names: Vec<&str> = ordered
2610 .iter()
2611 .filter(|l| l.kind == LabelKind::System)
2612 .map(|l| l.name.as_str())
2613 .collect();
2614 assert_eq!(system_names[0], "INBOX");
2616 assert_eq!(system_names[1], "STARRED");
2617 assert_eq!(system_names[2], "SENT");
2618 assert_eq!(system_names[3], "DRAFT");
2619 assert_eq!(system_names[4], "ARCHIVE");
2620 }
2621
2622 #[test]
2623 fn sidebar_items_put_inbox_before_all_mail() {
2624 let mut app = App::new();
2625 app.labels = make_test_labels();
2626
2627 let items = app.sidebar_items();
2628 let all_mail_index = items
2629 .iter()
2630 .position(|item| matches!(item, SidebarItem::AllMail))
2631 .unwrap();
2632
2633 assert!(matches!(
2634 items.first(),
2635 Some(SidebarItem::Label(label)) if label.name == "INBOX"
2636 ));
2637 assert!(all_mail_index > 0);
2638 }
2639
2640 #[test]
2641 fn sidebar_hidden_labels_not_shown() {
2642 let mut app = App::new();
2643 app.labels = make_test_labels();
2644 let ordered = app.ordered_visible_labels();
2645 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2646 assert!(
2647 !names.contains(&"CATEGORY_UPDATES"),
2648 "Gmail categories should be hidden"
2649 );
2650 }
2651
2652 #[test]
2653 fn sidebar_empty_system_labels_hidden_except_primary() {
2654 let mut app = App::new();
2655 app.labels = make_test_labels();
2656 let ordered = app.ordered_visible_labels();
2657 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2658 assert!(
2660 !names.contains(&"CHAT"),
2661 "Empty non-primary system labels should be hidden"
2662 );
2663 assert!(
2665 names.contains(&"DRAFT"),
2666 "Primary system labels shown even if empty"
2667 );
2668 assert!(
2669 names.contains(&"ARCHIVE"),
2670 "Archive should be shown as a primary system label even if empty"
2671 );
2672 assert!(
2674 names.contains(&"IMPORTANT"),
2675 "Non-empty system labels should be shown"
2676 );
2677 }
2678
2679 #[test]
2680 fn sidebar_user_labels_alphabetical() {
2681 let mut app = App::new();
2682 app.labels = make_test_labels();
2683 let ordered = app.ordered_visible_labels();
2684 let user_names: Vec<&str> = ordered
2685 .iter()
2686 .filter(|l| l.kind == LabelKind::User)
2687 .map(|l| l.name.as_str())
2688 .collect();
2689 assert_eq!(user_names, vec!["Personal", "Work"]);
2691 }
2692
2693 #[test]
2696 fn goto_inbox_sets_active_label() {
2697 let mut app = App::new();
2698 app.envelopes = make_test_envelopes(5);
2699 app.all_envelopes = app.envelopes.clone();
2700 app.labels = make_test_labels();
2701 app.apply(Action::GoToInbox);
2702 let label = app.labels.iter().find(|l| l.name == "INBOX").unwrap();
2703 assert!(
2704 app.active_label.is_none(),
2705 "GoToInbox should wait for fetch success before swapping active label"
2706 );
2707 assert_eq!(app.pending_active_label.as_ref().unwrap(), &label.id);
2708 assert!(
2709 app.pending_label_fetch.is_some(),
2710 "Should trigger label fetch"
2711 );
2712 }
2713
2714 #[test]
2715 fn goto_inbox_without_labels_records_desired_mailbox() {
2716 let mut app = App::new();
2717 app.apply(Action::GoToInbox);
2718 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
2719 assert!(app.pending_label_fetch.is_none());
2720 assert!(app.pending_active_label.is_none());
2721 }
2722
2723 #[test]
2724 fn labels_refresh_resolves_desired_inbox() {
2725 let mut app = App::new();
2726 app.desired_system_mailbox = Some("INBOX".into());
2727 app.labels = make_test_labels();
2728
2729 app.resolve_desired_system_mailbox();
2730
2731 let inbox_id = app
2732 .labels
2733 .iter()
2734 .find(|label| label.name == "INBOX")
2735 .unwrap()
2736 .id
2737 .clone();
2738 assert_eq!(app.pending_active_label.as_ref(), Some(&inbox_id));
2739 assert_eq!(app.pending_label_fetch.as_ref(), Some(&inbox_id));
2740 assert!(app.active_label.is_none());
2741 }
2742
2743 #[test]
2744 fn sync_completed_requests_live_refresh_even_without_active_label() {
2745 let mut app = App::new();
2746
2747 handle_daemon_event(
2748 &mut app,
2749 DaemonEvent::SyncCompleted {
2750 account_id: AccountId::new(),
2751 messages_synced: 5,
2752 },
2753 );
2754
2755 assert!(app.pending_labels_refresh);
2756 assert!(app.pending_all_envelopes_refresh);
2757 assert!(app.pending_status_refresh);
2758 assert!(app.pending_label_fetch.is_none());
2759 assert_eq!(app.status_message.as_deref(), Some("Synced 5 messages"));
2760 }
2761
2762 #[test]
2763 fn status_bar_uses_label_counts_instead_of_loaded_window() {
2764 let mut app = App::new();
2765 let mut envelopes = make_test_envelopes(5);
2766 if let Some(first) = envelopes.first_mut() {
2767 first.flags.remove(MessageFlags::READ);
2768 first.flags.insert(MessageFlags::STARRED);
2769 }
2770 app.envelopes = envelopes.clone();
2771 app.all_envelopes = envelopes;
2772 app.labels = make_test_labels();
2773 let inbox = app
2774 .labels
2775 .iter()
2776 .find(|label| label.name == "INBOX")
2777 .unwrap()
2778 .id
2779 .clone();
2780 app.active_label = Some(inbox);
2781 app.last_sync_status = Some("synced just now".into());
2782
2783 let state = app.status_bar_state();
2784
2785 assert_eq!(state.mailbox_name, "INBOX");
2786 assert_eq!(state.total_count, 10);
2787 assert_eq!(state.unread_count, 3);
2788 assert_eq!(state.starred_count, 2);
2789 assert_eq!(state.sync_status.as_deref(), Some("synced just now"));
2790 }
2791
2792 #[test]
2793 fn all_envelopes_refresh_updates_visible_all_mail() {
2794 let mut app = App::new();
2795 let envelopes = make_test_envelopes(4);
2796 app.active_label = None;
2797 app.search_active = false;
2798
2799 apply_all_envelopes_refresh(&mut app, envelopes.clone());
2800
2801 assert_eq!(app.all_envelopes.len(), 4);
2802 assert_eq!(app.envelopes.len(), 4);
2803 assert_eq!(app.selected_index, 0);
2804 }
2805
2806 #[test]
2807 fn all_envelopes_refresh_preserves_selection_when_possible() {
2808 let mut app = App::new();
2809 app.visible_height = 3;
2810 let initial = make_test_envelopes(4);
2811 app.all_envelopes = initial.clone();
2812 app.envelopes = initial.clone();
2813 app.selected_index = 2;
2814 app.scroll_offset = 1;
2815
2816 let mut refreshed = initial.clone();
2817 refreshed.push(make_test_envelopes(1).remove(0));
2818
2819 apply_all_envelopes_refresh(&mut app, refreshed);
2820
2821 assert_eq!(app.selected_index, 2);
2822 assert_eq!(app.envelopes[app.selected_index].id, initial[2].id);
2823 assert_eq!(app.scroll_offset, 1);
2824 }
2825
2826 #[test]
2827 fn label_counts_refresh_can_follow_empty_boot() {
2828 let mut app = App::new();
2829 app.desired_system_mailbox = Some("INBOX".into());
2830
2831 handle_daemon_event(
2832 &mut app,
2833 DaemonEvent::SyncCompleted {
2834 account_id: AccountId::new(),
2835 messages_synced: 0,
2836 },
2837 );
2838
2839 assert!(app.pending_labels_refresh);
2840 assert!(app.pending_all_envelopes_refresh);
2841 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
2842 }
2843
2844 #[test]
2845 fn clear_filter_restores_all_envelopes() {
2846 let mut app = App::new();
2847 app.envelopes = make_test_envelopes(10);
2848 app.all_envelopes = app.envelopes.clone();
2849 app.labels = make_test_labels();
2850 let inbox_id = app
2851 .labels
2852 .iter()
2853 .find(|l| l.name == "INBOX")
2854 .unwrap()
2855 .id
2856 .clone();
2857 app.active_label = Some(inbox_id);
2858 app.envelopes = vec![app.envelopes[0].clone()]; app.selected_index = 0;
2860 app.apply(Action::ClearFilter);
2861 assert!(app.active_label.is_none());
2862 assert_eq!(app.envelopes.len(), 10, "Should restore full list");
2863 }
2864
2865 #[test]
2868 fn archive_removes_from_list() {
2869 let mut app = App::new();
2870 app.envelopes = make_test_envelopes(5);
2871 app.all_envelopes = app.envelopes.clone();
2872 let _id = app.envelopes[2].id.clone();
2873 app.apply(Action::Archive);
2875 assert!(!app.pending_mutation_queue.is_empty());
2877 let (_, effect) = app.pending_mutation_queue.remove(0);
2879 match effect {
2881 MutationEffect::RemoveFromList(remove_id) => {
2882 app.envelopes.retain(|e| e.id != remove_id);
2883 app.all_envelopes.retain(|e| e.id != remove_id);
2884 }
2885 _ => panic!("Expected RemoveFromList"),
2886 }
2887 assert_eq!(app.envelopes.len(), 4);
2888 }
2889
2890 #[test]
2891 fn star_updates_flags_in_place() {
2892 let mut app = App::new();
2893 app.envelopes = make_test_envelopes(3);
2894 app.all_envelopes = app.envelopes.clone();
2895 assert!(!app.envelopes[0].flags.contains(MessageFlags::STARRED));
2897 app.apply(Action::Star);
2898 assert!(!app.pending_mutation_queue.is_empty());
2899 assert_eq!(app.pending_mutation_count, 1);
2900 assert!(app.envelopes[0].flags.contains(MessageFlags::STARRED));
2901 }
2902
2903 #[test]
2904 fn bulk_mark_read_applies_flags_when_confirmed() {
2905 let mut app = App::new();
2906 let mut envelopes = make_test_envelopes(3);
2907 for envelope in &mut envelopes {
2908 envelope.flags.remove(MessageFlags::READ);
2909 }
2910 app.envelopes = envelopes.clone();
2911 app.all_envelopes = envelopes.clone();
2912 app.selected_set = envelopes
2913 .iter()
2914 .map(|envelope| envelope.id.clone())
2915 .collect();
2916
2917 app.apply(Action::MarkRead);
2918 assert!(app.pending_mutation_queue.is_empty());
2919 assert!(app.pending_bulk_confirm.is_some());
2920 assert!(app
2921 .envelopes
2922 .iter()
2923 .all(|envelope| !envelope.flags.contains(MessageFlags::READ)));
2924
2925 app.apply(Action::OpenSelected);
2926
2927 assert_eq!(app.pending_mutation_queue.len(), 1);
2928 assert_eq!(app.pending_mutation_count, 1);
2929 assert!(app.pending_bulk_confirm.is_none());
2930 assert!(app
2931 .envelopes
2932 .iter()
2933 .all(|envelope| envelope.flags.contains(MessageFlags::READ)));
2934 assert_eq!(
2935 app.pending_mutation_status.as_deref(),
2936 Some("Marking 3 messages as read...")
2937 );
2938 }
2939
2940 #[test]
2941 fn status_bar_shows_pending_mutation_indicator_after_other_actions() {
2942 let mut app = App::new();
2943 let mut envelopes = make_test_envelopes(2);
2944 for envelope in &mut envelopes {
2945 envelope.flags.remove(MessageFlags::READ);
2946 }
2947 app.envelopes = envelopes.clone();
2948 app.all_envelopes = envelopes;
2949
2950 app.apply(Action::MarkRead);
2951 app.apply(Action::MoveDown);
2952
2953 let state = app.status_bar_state();
2954 assert_eq!(state.pending_mutation_count, 1);
2955 assert_eq!(
2956 state.pending_mutation_status.as_deref(),
2957 Some("Marking 1 message as read...")
2958 );
2959 }
2960
2961 #[test]
2962 fn mark_read_and_archive_marks_read_optimistically_and_queues_combined_mutation() {
2963 let mut app = App::new();
2964 let mut envelopes = make_test_envelopes(1);
2965 envelopes[0].flags.remove(MessageFlags::READ);
2966 app.envelopes = envelopes.clone();
2967 app.all_envelopes = envelopes;
2968
2969 app.apply(Action::MarkReadAndArchive);
2970
2971 assert!(app.envelopes[0].flags.contains(MessageFlags::READ));
2972 assert_eq!(app.pending_mutation_queue.len(), 1);
2973 match &app.pending_mutation_queue[0].0 {
2974 Request::Mutation(MutationCommand::ReadAndArchive { message_ids }) => {
2975 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
2976 }
2977 other => panic!("expected read-and-archive mutation, got {other:?}"),
2978 }
2979 }
2980
2981 #[test]
2982 fn mutation_failure_opens_error_modal_and_refreshes_mailbox() {
2983 let mut app = App::new();
2984
2985 app.show_mutation_failure(&MxrError::Ipc("boom".into()));
2986 app.refresh_mailbox_after_mutation_failure();
2987
2988 assert!(app.error_modal.is_some());
2989 assert_eq!(
2990 app.error_modal.as_ref().map(|modal| modal.title.as_str()),
2991 Some("Mutation Failed")
2992 );
2993 assert!(app.pending_labels_refresh);
2994 assert!(app.pending_all_envelopes_refresh);
2995 assert!(app.pending_status_refresh);
2996 assert!(app.pending_subscriptions_refresh);
2997 }
2998
2999 #[test]
3000 fn archive_viewing_message_effect() {
3001 let mut app = App::new();
3002 app.envelopes = make_test_envelopes(3);
3003 app.all_envelopes = app.envelopes.clone();
3004 app.apply(Action::OpenSelected);
3006 assert!(app.viewing_envelope.is_some());
3007 let viewing_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3008 app.apply(Action::Archive);
3011 let (_, effect) = app.pending_mutation_queue.remove(0);
3012 match &effect {
3014 MutationEffect::RemoveFromList(id) => {
3015 assert_eq!(*id, viewing_id);
3016 }
3017 _ => panic!("Expected RemoveFromList"),
3018 }
3019 }
3020
3021 #[test]
3024 fn mail_list_title_shows_message_count() {
3025 let mut app = App::new();
3026 app.envelopes = make_test_envelopes(5);
3027 app.all_envelopes = app.envelopes.clone();
3028 let title = app.mail_list_title();
3029 assert!(title.contains("5"), "Title should show message count");
3030 assert!(
3031 title.contains("Threads"),
3032 "Default title should say Threads"
3033 );
3034 }
3035
3036 #[test]
3037 fn mail_list_title_shows_label_name() {
3038 let mut app = App::new();
3039 app.envelopes = make_test_envelopes(5);
3040 app.all_envelopes = app.envelopes.clone();
3041 app.labels = make_test_labels();
3042 let inbox_id = app
3043 .labels
3044 .iter()
3045 .find(|l| l.name == "INBOX")
3046 .unwrap()
3047 .id
3048 .clone();
3049 app.active_label = Some(inbox_id);
3050 let title = app.mail_list_title();
3051 assert!(
3052 title.contains("Inbox"),
3053 "Title should show humanized label name"
3054 );
3055 }
3056
3057 #[test]
3058 fn mail_list_title_shows_search_query() {
3059 let mut app = App::new();
3060 app.envelopes = make_test_envelopes(5);
3061 app.all_envelopes = app.envelopes.clone();
3062 app.search_active = true;
3063 app.search_bar.query = "deployment".to_string();
3064 let title = app.mail_list_title();
3065 assert!(
3066 title.contains("deployment"),
3067 "Title should show search query"
3068 );
3069 assert!(title.contains("Search"), "Title should indicate search");
3070 }
3071
3072 #[test]
3073 fn message_view_body_display() {
3074 let mut app = App::new();
3075 app.envelopes = make_test_envelopes(3);
3076 app.all_envelopes = app.envelopes.clone();
3077 app.apply(Action::OpenMessageView);
3078 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3079 app.body_view_state = BodyViewState::Ready {
3080 raw: "Hello".into(),
3081 rendered: "Hello".into(),
3082 source: BodySource::Plain,
3083 };
3084 assert_eq!(app.body_view_state.display_text(), Some("Hello"));
3085 app.apply(Action::CloseMessageView);
3086 assert!(matches!(app.body_view_state, BodyViewState::Empty { .. }));
3087 }
3088
3089 #[test]
3090 fn close_message_view_preserves_reader_mode() {
3091 let mut app = App::new();
3092 app.envelopes = make_test_envelopes(1);
3093 app.all_envelopes = app.envelopes.clone();
3094 app.apply(Action::OpenMessageView);
3095
3096 app.apply(Action::CloseMessageView);
3097
3098 assert!(app.reader_mode);
3099 }
3100
3101 #[test]
3102 fn open_selected_populates_visible_thread_messages() {
3103 let mut app = App::new();
3104 app.envelopes = make_test_envelopes(3);
3105 let shared_thread = ThreadId::new();
3106 app.envelopes[0].thread_id = shared_thread.clone();
3107 app.envelopes[1].thread_id = shared_thread;
3108 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3109 app.envelopes[1].date = chrono::Utc::now();
3110 app.all_envelopes = app.envelopes.clone();
3111
3112 app.apply(Action::OpenSelected);
3113
3114 assert_eq!(app.viewed_thread_messages.len(), 2);
3115 assert_eq!(app.viewed_thread_messages[0].id, app.envelopes[0].id);
3116 assert_eq!(app.viewed_thread_messages[1].id, app.envelopes[1].id);
3117 }
3118
3119 #[test]
3120 fn mail_list_defaults_to_threads() {
3121 let mut app = App::new();
3122 app.envelopes = make_test_envelopes(3);
3123 let shared_thread = ThreadId::new();
3124 app.envelopes[0].thread_id = shared_thread.clone();
3125 app.envelopes[1].thread_id = shared_thread;
3126 app.all_envelopes = app.envelopes.clone();
3127
3128 assert_eq!(app.mail_list_rows().len(), 2);
3129 assert_eq!(
3130 app.selected_mail_row().map(|row| row.message_count),
3131 Some(2)
3132 );
3133 }
3134
3135 #[test]
3136 fn open_thread_focuses_latest_unread_message() {
3137 let mut app = App::new();
3138 app.envelopes = make_test_envelopes(3);
3139 let shared_thread = ThreadId::new();
3140 app.envelopes[0].thread_id = shared_thread.clone();
3141 app.envelopes[1].thread_id = shared_thread;
3142 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(10);
3143 app.envelopes[1].date = chrono::Utc::now();
3144 app.envelopes[0].flags = MessageFlags::READ;
3145 app.envelopes[1].flags = MessageFlags::empty();
3146 app.all_envelopes = app.envelopes.clone();
3147
3148 app.apply(Action::OpenSelected);
3149
3150 assert_eq!(app.thread_selected_index, 1);
3151 assert_eq!(
3152 app.focused_thread_envelope().map(|env| env.id.clone()),
3153 Some(app.envelopes[1].id.clone())
3154 );
3155 }
3156
3157 #[test]
3158 fn open_selected_marks_unread_message_read_after_dwell() {
3159 let mut app = App::new();
3160 app.envelopes = make_test_envelopes(1);
3161 app.envelopes[0].flags = MessageFlags::empty();
3162 app.all_envelopes = app.envelopes.clone();
3163
3164 app.apply(Action::OpenSelected);
3165
3166 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3167 assert!(!app.all_envelopes[0].flags.contains(MessageFlags::READ));
3168 assert!(!app.viewed_thread_messages[0]
3169 .flags
3170 .contains(MessageFlags::READ));
3171 assert!(!app
3172 .viewing_envelope
3173 .as_ref()
3174 .unwrap()
3175 .flags
3176 .contains(MessageFlags::READ));
3177 assert!(app.pending_mutation_queue.is_empty());
3178
3179 app.expire_pending_preview_read_for_tests();
3180 app.tick();
3181
3182 assert!(app.envelopes[0].flags.contains(MessageFlags::READ));
3183 assert!(app.all_envelopes[0].flags.contains(MessageFlags::READ));
3184 assert!(app.viewed_thread_messages[0]
3185 .flags
3186 .contains(MessageFlags::READ));
3187 assert!(app
3188 .viewing_envelope
3189 .as_ref()
3190 .unwrap()
3191 .flags
3192 .contains(MessageFlags::READ));
3193 assert_eq!(app.pending_mutation_queue.len(), 1);
3194 match &app.pending_mutation_queue[0].0 {
3195 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3196 assert!(*read);
3197 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3198 }
3199 other => panic!("expected set-read mutation, got {other:?}"),
3200 }
3201 }
3202
3203 #[test]
3204 fn open_selected_on_read_message_does_not_queue_read_mutation() {
3205 let mut app = App::new();
3206 app.envelopes = make_test_envelopes(1);
3207 app.envelopes[0].flags = MessageFlags::READ;
3208 app.all_envelopes = app.envelopes.clone();
3209
3210 app.apply(Action::OpenSelected);
3211 app.expire_pending_preview_read_for_tests();
3212 app.tick();
3213
3214 assert!(app.pending_mutation_queue.is_empty());
3215 }
3216
3217 #[test]
3218 fn reopening_same_message_does_not_queue_duplicate_read_mutation() {
3219 let mut app = App::new();
3220 app.envelopes = make_test_envelopes(1);
3221 app.envelopes[0].flags = MessageFlags::empty();
3222 app.all_envelopes = app.envelopes.clone();
3223
3224 app.apply(Action::OpenSelected);
3225 app.apply(Action::OpenSelected);
3226
3227 assert!(app.pending_mutation_queue.is_empty());
3228 app.expire_pending_preview_read_for_tests();
3229 app.tick();
3230 assert_eq!(app.pending_mutation_queue.len(), 1);
3231 }
3232
3233 #[test]
3234 fn thread_move_down_changes_reply_target() {
3235 let mut app = App::new();
3236 app.envelopes = make_test_envelopes(2);
3237 let shared_thread = ThreadId::new();
3238 app.envelopes[0].thread_id = shared_thread.clone();
3239 app.envelopes[1].thread_id = shared_thread;
3240 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3241 app.envelopes[1].date = chrono::Utc::now();
3242 app.envelopes[0].flags = MessageFlags::empty();
3243 app.envelopes[1].flags = MessageFlags::READ;
3244 app.all_envelopes = app.envelopes.clone();
3245
3246 app.apply(Action::OpenSelected);
3247 assert_eq!(
3248 app.focused_thread_envelope().map(|env| env.id.clone()),
3249 Some(app.envelopes[0].id.clone())
3250 );
3251
3252 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3253
3254 assert_eq!(
3255 app.focused_thread_envelope().map(|env| env.id.clone()),
3256 Some(app.envelopes[1].id.clone())
3257 );
3258 app.apply(Action::Reply);
3259 assert_eq!(
3260 app.pending_compose,
3261 Some(super::app::ComposeAction::Reply {
3262 message_id: app.envelopes[1].id.clone()
3263 })
3264 );
3265 }
3266
3267 #[test]
3268 fn thread_focus_change_marks_newly_focused_unread_message_read_after_dwell() {
3269 let mut app = App::new();
3270 app.envelopes = make_test_envelopes(2);
3271 let shared_thread = ThreadId::new();
3272 app.envelopes[0].thread_id = shared_thread.clone();
3273 app.envelopes[1].thread_id = shared_thread;
3274 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3275 app.envelopes[1].date = chrono::Utc::now();
3276 app.envelopes[0].flags = MessageFlags::empty();
3277 app.envelopes[1].flags = MessageFlags::empty();
3278 app.all_envelopes = app.envelopes.clone();
3279
3280 app.apply(Action::OpenSelected);
3281 assert_eq!(app.thread_selected_index, 1);
3282 assert!(app.pending_mutation_queue.is_empty());
3283
3284 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
3285
3286 assert_eq!(app.thread_selected_index, 0);
3287 assert!(!app.viewed_thread_messages[0]
3288 .flags
3289 .contains(MessageFlags::READ));
3290 assert!(app.pending_mutation_queue.is_empty());
3291
3292 app.expire_pending_preview_read_for_tests();
3293 app.tick();
3294
3295 assert!(app.viewed_thread_messages[0]
3296 .flags
3297 .contains(MessageFlags::READ));
3298 assert!(app
3299 .viewing_envelope
3300 .as_ref()
3301 .unwrap()
3302 .flags
3303 .contains(MessageFlags::READ));
3304 assert_eq!(app.pending_mutation_queue.len(), 1);
3305 match &app.pending_mutation_queue[0].0 {
3306 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3307 assert!(*read);
3308 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3309 }
3310 other => panic!("expected set-read mutation, got {other:?}"),
3311 }
3312 }
3313
3314 #[test]
3315 fn preview_navigation_only_marks_message_read_after_settling() {
3316 let mut app = App::new();
3317 app.envelopes = make_test_envelopes(2);
3318 app.envelopes[0].flags = MessageFlags::empty();
3319 app.envelopes[1].flags = MessageFlags::empty();
3320 app.envelopes[0].thread_id = ThreadId::new();
3321 app.envelopes[1].thread_id = ThreadId::new();
3322 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(1);
3323 app.envelopes[1].date = chrono::Utc::now();
3324 app.all_envelopes = app.envelopes.clone();
3325
3326 app.apply(Action::OpenSelected);
3327 app.apply(Action::MoveDown);
3328
3329 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3330 assert!(!app.envelopes[1].flags.contains(MessageFlags::READ));
3331 assert!(app.pending_mutation_queue.is_empty());
3332
3333 app.expire_pending_preview_read_for_tests();
3334 app.tick();
3335
3336 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3337 assert!(app.envelopes[1].flags.contains(MessageFlags::READ));
3338 assert_eq!(app.pending_mutation_queue.len(), 1);
3339 match &app.pending_mutation_queue[0].0 {
3340 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3341 assert!(*read);
3342 assert_eq!(message_ids, &vec![app.envelopes[1].id.clone()]);
3343 }
3344 other => panic!("expected set-read mutation, got {other:?}"),
3345 }
3346 }
3347
3348 #[test]
3349 fn help_action_toggles_modal_state() {
3350 let mut app = App::new();
3351
3352 app.apply(Action::Help);
3353 assert!(app.help_modal_open);
3354
3355 app.apply(Action::Help);
3356 assert!(!app.help_modal_open);
3357 }
3358
3359 #[test]
3360 fn open_search_screen_activates_dedicated_search_workspace() {
3361 let mut app = App::new();
3362 app.apply(Action::OpenSearchScreen);
3363 assert_eq!(app.screen, Screen::Search);
3364 assert!(app.search_page.editing);
3365 }
3366
3367 #[test]
3368 fn search_screen_typing_updates_results_and_queues_search() {
3369 let mut app = App::new();
3370 let mut envelopes = make_test_envelopes(2);
3371 envelopes[0].subject = "crates.io release".into();
3372 envelopes[0].snippet = "mxr publish".into();
3373 envelopes[1].subject = "support request".into();
3374 envelopes[1].snippet = "billing".into();
3375 app.envelopes = envelopes.clone();
3376 app.all_envelopes = envelopes;
3377
3378 app.apply(Action::OpenSearchScreen);
3379 app.search_page.query.clear();
3380 app.search_page.results = app.all_envelopes.clone();
3381
3382 for ch in "crate".chars() {
3383 let action = app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
3384 assert!(action.is_none());
3385 }
3386
3387 assert_eq!(app.search_page.query, "crate");
3388 assert_eq!(app.search_page.results.len(), 1);
3389 assert_eq!(app.search_page.results[0].subject, "crates.io release");
3390 assert_eq!(
3391 app.pending_search,
3392 Some(("crate".into(), mxr_core::SearchMode::Lexical))
3393 );
3394 }
3395
3396 #[test]
3397 fn open_rules_screen_marks_refresh_pending() {
3398 let mut app = App::new();
3399 app.apply(Action::OpenRulesScreen);
3400 assert_eq!(app.screen, Screen::Rules);
3401 assert!(app.rules_page.refresh_pending);
3402 }
3403
3404 #[test]
3405 fn open_diagnostics_screen_marks_refresh_pending() {
3406 let mut app = App::new();
3407 app.apply(Action::OpenDiagnosticsScreen);
3408 assert_eq!(app.screen, Screen::Diagnostics);
3409 assert!(app.diagnostics_page.refresh_pending);
3410 }
3411
3412 #[test]
3413 fn diagnostics_shift_l_opens_logs() {
3414 let mut app = App::new();
3415 app.screen = Screen::Diagnostics;
3416
3417 let action = app.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT));
3418
3419 assert_eq!(action, Some(Action::OpenLogs));
3420 }
3421
3422 #[test]
3423 fn diagnostics_tab_cycles_selected_pane() {
3424 let mut app = App::new();
3425 app.screen = Screen::Diagnostics;
3426
3427 let action = app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
3428
3429 assert!(action.is_none());
3430 assert_eq!(
3431 app.diagnostics_page.selected_pane,
3432 crate::app::DiagnosticsPaneKind::Data
3433 );
3434 }
3435
3436 #[test]
3437 fn diagnostics_enter_toggles_fullscreen_for_selected_pane() {
3438 let mut app = App::new();
3439 app.screen = Screen::Diagnostics;
3440 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Logs;
3441
3442 assert!(app
3443 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3444 .is_none());
3445 assert_eq!(
3446 app.diagnostics_page.fullscreen_pane,
3447 Some(crate::app::DiagnosticsPaneKind::Logs)
3448 );
3449 assert!(app
3450 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
3451 .is_none());
3452 assert_eq!(app.diagnostics_page.fullscreen_pane, None);
3453 }
3454
3455 #[test]
3456 fn diagnostics_d_opens_selected_pane_details() {
3457 let mut app = App::new();
3458 app.screen = Screen::Diagnostics;
3459 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Events;
3460
3461 let action = app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
3462
3463 assert_eq!(action, Some(Action::OpenDiagnosticsPaneDetails));
3464 }
3465
3466 #[test]
3467 fn open_accounts_screen_marks_refresh_pending() {
3468 let mut app = App::new();
3469 app.apply(Action::OpenAccountsScreen);
3470 assert_eq!(app.screen, Screen::Accounts);
3471 assert!(app.accounts_page.refresh_pending);
3472 }
3473
3474 #[test]
3475 fn new_account_form_opens_from_accounts_screen() {
3476 let mut app = App::new();
3477 app.apply(Action::OpenAccountsScreen);
3478 app.apply(Action::OpenAccountFormNew);
3479
3480 assert_eq!(app.screen, Screen::Accounts);
3481 assert!(app.accounts_page.form.visible);
3482 assert_eq!(
3483 app.accounts_page.form.mode,
3484 crate::app::AccountFormMode::Gmail
3485 );
3486 }
3487
3488 #[test]
3489 fn app_from_empty_config_enters_account_onboarding() {
3490 let config = mxr_config::MxrConfig::default();
3491 let app = App::from_config(&config);
3492
3493 assert_eq!(app.screen, Screen::Accounts);
3494 assert!(app.accounts_page.refresh_pending);
3495 assert!(app.accounts_page.onboarding_required);
3496 assert!(app.accounts_page.onboarding_modal_open);
3497 }
3498
3499 #[test]
3500 fn onboarding_confirm_opens_new_account_form() {
3501 let config = mxr_config::MxrConfig::default();
3502 let mut app = App::from_config(&config);
3503
3504 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3505
3506 assert_eq!(app.screen, Screen::Accounts);
3507 assert!(app.accounts_page.form.visible);
3508 assert!(!app.accounts_page.onboarding_modal_open);
3509 }
3510
3511 #[test]
3512 fn onboarding_q_quits() {
3513 let config = mxr_config::MxrConfig::default();
3514 let mut app = App::from_config(&config);
3515
3516 let action = app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
3517
3518 assert_eq!(action, Some(Action::QuitView));
3519 }
3520
3521 #[test]
3522 fn onboarding_blocks_mailbox_screen_until_account_exists() {
3523 let config = mxr_config::MxrConfig::default();
3524 let mut app = App::from_config(&config);
3525
3526 app.apply(Action::OpenMailboxScreen);
3527
3528 assert_eq!(app.screen, Screen::Accounts);
3529 assert!(app.accounts_page.onboarding_required);
3530 }
3531
3532 #[test]
3533 fn account_form_h_and_l_switch_modes_from_any_field() {
3534 let mut app = App::new();
3535 app.apply(Action::OpenAccountFormNew);
3536 app.accounts_page.form.active_field = 2;
3537
3538 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3539 assert_eq!(
3540 app.accounts_page.form.mode,
3541 crate::app::AccountFormMode::ImapSmtp
3542 );
3543
3544 app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
3545 assert_eq!(
3546 app.accounts_page.form.mode,
3547 crate::app::AccountFormMode::Gmail
3548 );
3549 }
3550
3551 #[test]
3552 fn account_form_tab_on_mode_cycles_modes() {
3553 let mut app = App::new();
3554 app.apply(Action::OpenAccountFormNew);
3555 app.accounts_page.form.active_field = 0;
3556
3557 app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
3558 assert_eq!(
3559 app.accounts_page.form.mode,
3560 crate::app::AccountFormMode::ImapSmtp
3561 );
3562
3563 app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
3564 assert_eq!(
3565 app.accounts_page.form.mode,
3566 crate::app::AccountFormMode::Gmail
3567 );
3568 }
3569
3570 #[test]
3571 fn account_form_mode_switch_with_input_requires_confirmation() {
3572 let mut app = App::new();
3573 app.apply(Action::OpenAccountFormNew);
3574 app.accounts_page.form.key = "work".into();
3575
3576 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3577
3578 assert_eq!(
3579 app.accounts_page.form.mode,
3580 crate::app::AccountFormMode::Gmail
3581 );
3582 assert_eq!(
3583 app.accounts_page.form.pending_mode_switch,
3584 Some(crate::app::AccountFormMode::ImapSmtp)
3585 );
3586 }
3587
3588 #[test]
3589 fn account_form_mode_switch_confirmation_applies_mode_change() {
3590 let mut app = App::new();
3591 app.apply(Action::OpenAccountFormNew);
3592 app.accounts_page.form.key = "work".into();
3593
3594 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3595 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3596
3597 assert_eq!(
3598 app.accounts_page.form.mode,
3599 crate::app::AccountFormMode::ImapSmtp
3600 );
3601 assert!(app.accounts_page.form.pending_mode_switch.is_none());
3602 }
3603
3604 #[test]
3605 fn account_form_mode_switch_confirmation_cancel_keeps_mode() {
3606 let mut app = App::new();
3607 app.apply(Action::OpenAccountFormNew);
3608 app.accounts_page.form.key = "work".into();
3609
3610 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3611 app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
3612
3613 assert_eq!(
3614 app.accounts_page.form.mode,
3615 crate::app::AccountFormMode::Gmail
3616 );
3617 assert!(app.accounts_page.form.pending_mode_switch.is_none());
3618 }
3619
3620 #[test]
3621 fn flattened_sidebar_navigation_reaches_saved_searches() {
3622 let mut app = App::new();
3623 app.labels = vec![Label {
3624 id: LabelId::new(),
3625 account_id: AccountId::new(),
3626 provider_id: "inbox".into(),
3627 name: "INBOX".into(),
3628 kind: LabelKind::System,
3629 color: None,
3630 unread_count: 1,
3631 total_count: 3,
3632 }];
3633 app.saved_searches = vec![SavedSearch {
3634 id: SavedSearchId::new(),
3635 account_id: None,
3636 name: "Unread".into(),
3637 query: "is:unread".into(),
3638 search_mode: SearchMode::Lexical,
3639 sort: SortOrder::DateDesc,
3640 icon: None,
3641 position: 0,
3642 created_at: chrono::Utc::now(),
3643 }];
3644 app.active_pane = ActivePane::Sidebar;
3645
3646 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3647 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3648 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3649
3650 assert!(matches!(
3651 app.selected_sidebar_item(),
3652 Some(super::app::SidebarItem::SavedSearch(_))
3653 ));
3654 }
3655
3656 #[test]
3657 fn toggle_select_advances_cursor_and_updates_preview() {
3658 let mut app = App::new();
3659 app.envelopes = make_test_envelopes(2);
3660 app.all_envelopes = app.envelopes.clone();
3661 app.apply(Action::OpenSelected);
3662
3663 app.apply(Action::ToggleSelect);
3664
3665 assert_eq!(app.selected_index, 1);
3666 assert_eq!(
3667 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3668 Some(app.envelopes[1].id.clone())
3669 );
3670 assert!(matches!(
3671 app.body_view_state,
3672 BodyViewState::Loading { ref preview }
3673 if preview.as_deref() == Some("Snippet 1")
3674 ));
3675 }
3676
3677 #[test]
3678 fn opening_search_result_enters_mailbox_thread_view() {
3679 let mut app = App::new();
3680 app.screen = Screen::Search;
3681 app.search_page.results = make_test_envelopes(2);
3682 app.search_page.selected_index = 1;
3683
3684 app.apply(Action::OpenSelected);
3685
3686 assert_eq!(app.screen, Screen::Mailbox);
3687 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3688 assert_eq!(app.active_pane, ActivePane::MessageView);
3689 assert_eq!(
3690 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3691 Some(app.search_page.results[1].id.clone())
3692 );
3693 }
3694
3695 #[test]
3696 fn attachment_list_opens_modal_for_current_message() {
3697 let mut app = App::new();
3698 app.envelopes = make_test_envelopes(1);
3699 app.all_envelopes = app.envelopes.clone();
3700 let env = app.envelopes[0].clone();
3701 app.body_cache.insert(
3702 env.id.clone(),
3703 MessageBody {
3704 message_id: env.id.clone(),
3705 text_plain: Some("hello".into()),
3706 text_html: None,
3707 attachments: vec![AttachmentMeta {
3708 id: AttachmentId::new(),
3709 message_id: env.id.clone(),
3710 filename: "report.pdf".into(),
3711 mime_type: "application/pdf".into(),
3712 size_bytes: 1024,
3713 local_path: None,
3714 provider_id: "att-1".into(),
3715 }],
3716 fetched_at: chrono::Utc::now(),
3717 metadata: Default::default(),
3718 },
3719 );
3720
3721 app.apply(Action::OpenSelected);
3722 app.apply(Action::AttachmentList);
3723
3724 assert!(app.attachment_panel.visible);
3725 assert_eq!(app.attachment_panel.attachments.len(), 1);
3726 assert_eq!(app.attachment_panel.attachments[0].filename, "report.pdf");
3727 }
3728
3729 #[test]
3730 fn unchanged_editor_result_disables_send_actions() {
3731 let temp = std::env::temp_dir().join(format!(
3732 "mxr-compose-test-{}-{}.md",
3733 std::process::id(),
3734 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
3735 ));
3736 let content = "---\nto: a@example.com\ncc: \"\"\nbcc: \"\"\nsubject: Hello\nfrom: me@example.com\nattach: []\n---\n\nBody\n";
3737 std::fs::write(&temp, content).unwrap();
3738
3739 let pending = pending_send_from_edited_draft(&ComposeReadyData {
3740 draft_path: temp.clone(),
3741 cursor_line: 1,
3742 initial_content: content.to_string(),
3743 })
3744 .unwrap()
3745 .expect("pending send should exist");
3746
3747 assert!(!pending.allow_send);
3748
3749 let _ = std::fs::remove_file(temp);
3750 }
3751
3752 #[test]
3753 fn send_key_is_ignored_for_unchanged_draft_confirmation() {
3754 let mut app = App::new();
3755 app.pending_send_confirm = Some(PendingSend {
3756 fm: mxr_compose::frontmatter::ComposeFrontmatter {
3757 to: "a@example.com".into(),
3758 cc: String::new(),
3759 bcc: String::new(),
3760 subject: "Hello".into(),
3761 from: "me@example.com".into(),
3762 in_reply_to: None,
3763 references: vec![],
3764 attach: vec![],
3765 },
3766 body: "Body".into(),
3767 draft_path: std::path::PathBuf::from("/tmp/draft.md"),
3768 allow_send: false,
3769 });
3770
3771 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
3772
3773 assert!(app.pending_send_confirm.is_some());
3774 assert!(app.pending_mutation_queue.is_empty());
3775 }
3776
3777 #[test]
3778 fn mail_list_l_opens_label_picker_not_message() {
3779 let mut app = App::new();
3780 app.active_pane = ActivePane::MailList;
3781
3782 let action = app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3783
3784 assert_eq!(action, Some(Action::ApplyLabel));
3785 }
3786
3787 #[test]
3788 fn back_clears_selection_before_other_mail_list_back_behavior() {
3789 let mut app = App::new();
3790 app.envelopes = make_test_envelopes(2);
3791 app.all_envelopes = app.envelopes.clone();
3792 app.selected_set.insert(app.envelopes[0].id.clone());
3793
3794 app.apply(Action::Back);
3795
3796 assert!(app.selected_set.is_empty());
3797 assert_eq!(app.status_message.as_deref(), Some("Selection cleared"));
3798 }
3799
3800 #[test]
3801 fn bulk_archive_requires_confirmation_before_queueing() {
3802 let mut app = App::new();
3803 app.envelopes = make_test_envelopes(3);
3804 app.all_envelopes = app.envelopes.clone();
3805 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
3806
3807 app.apply(Action::Archive);
3808
3809 assert!(app.pending_mutation_queue.is_empty());
3810 assert!(app.pending_bulk_confirm.is_some());
3811 }
3812
3813 #[test]
3814 fn confirming_bulk_archive_queues_mutation_and_clears_selection() {
3815 let mut app = App::new();
3816 app.envelopes = make_test_envelopes(3);
3817 app.all_envelopes = app.envelopes.clone();
3818 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
3819 app.apply(Action::Archive);
3820
3821 app.apply(Action::OpenSelected);
3822
3823 assert!(app.pending_bulk_confirm.is_none());
3824 assert_eq!(app.pending_mutation_queue.len(), 1);
3825 assert!(app.selected_set.is_empty());
3826 }
3827
3828 #[test]
3829 fn command_palette_includes_major_mail_actions() {
3830 let labels: Vec<String> = default_commands()
3831 .into_iter()
3832 .map(|cmd| cmd.label)
3833 .collect();
3834 assert!(labels.contains(&"Reply".to_string()));
3835 assert!(labels.contains(&"Reply All".to_string()));
3836 assert!(labels.contains(&"Archive".to_string()));
3837 assert!(labels.contains(&"Delete".to_string()));
3838 assert!(labels.contains(&"Apply Label".to_string()));
3839 assert!(labels.contains(&"Snooze".to_string()));
3840 assert!(labels.contains(&"Clear Selection".to_string()));
3841 assert!(labels.contains(&"Open Accounts Page".to_string()));
3842 assert!(labels.contains(&"New IMAP/SMTP Account".to_string()));
3843 assert!(labels.contains(&"Set Default Account".to_string()));
3844 }
3845
3846 #[test]
3847 fn local_label_changes_update_open_message() {
3848 let mut app = App::new();
3849 app.labels = make_test_labels();
3850 app.envelopes = make_test_envelopes(1);
3851 app.all_envelopes = app.envelopes.clone();
3852 app.apply(Action::OpenSelected);
3853
3854 let user_label = app
3855 .labels
3856 .iter()
3857 .find(|label| label.name == "Work")
3858 .unwrap()
3859 .clone();
3860 let message_id = app.envelopes[0].id.clone();
3861
3862 app.apply_local_label_refs(
3863 std::slice::from_ref(&message_id),
3864 std::slice::from_ref(&user_label.name),
3865 &[],
3866 );
3867
3868 assert!(app
3869 .viewing_envelope
3870 .as_ref()
3871 .unwrap()
3872 .label_provider_ids
3873 .contains(&user_label.provider_id));
3874 }
3875
3876 #[test]
3877 fn snooze_action_opens_modal_then_queues_request() {
3878 let mut app = App::new();
3879 app.envelopes = make_test_envelopes(1);
3880 app.all_envelopes = app.envelopes.clone();
3881
3882 app.apply(Action::Snooze);
3883 assert!(app.snooze_panel.visible);
3884
3885 app.apply(Action::Snooze);
3886 assert!(!app.snooze_panel.visible);
3887 assert_eq!(app.pending_mutation_queue.len(), 1);
3888 match &app.pending_mutation_queue[0].0 {
3889 Request::Snooze {
3890 message_id,
3891 wake_at,
3892 } => {
3893 assert_eq!(message_id, &app.envelopes[0].id);
3894 assert!(*wake_at > chrono::Utc::now());
3895 }
3896 other => panic!("expected snooze request, got {other:?}"),
3897 }
3898 }
3899
3900 #[test]
3901 fn open_selected_cache_miss_enters_loading_with_snippet_preview() {
3902 let mut app = App::new();
3903 app.envelopes = make_test_envelopes(1);
3904 app.all_envelopes = app.envelopes.clone();
3905
3906 app.apply(Action::OpenSelected);
3907
3908 assert!(matches!(
3909 app.body_view_state,
3910 BodyViewState::Loading { ref preview }
3911 if preview.as_deref() == Some("Snippet 0")
3912 ));
3913 assert_eq!(app.queued_body_fetches, vec![app.envelopes[0].id.clone()]);
3914 assert!(app.in_flight_body_requests.contains(&app.envelopes[0].id));
3915 }
3916
3917 #[test]
3918 fn cached_plain_body_resolves_ready_state() {
3919 let mut app = App::new();
3920 app.envelopes = make_test_envelopes(1);
3921 app.all_envelopes = app.envelopes.clone();
3922 let env = app.envelopes[0].clone();
3923
3924 app.body_cache.insert(
3925 env.id.clone(),
3926 MessageBody {
3927 message_id: env.id.clone(),
3928 text_plain: Some("Plain body".into()),
3929 text_html: None,
3930 attachments: vec![],
3931 fetched_at: chrono::Utc::now(),
3932 metadata: Default::default(),
3933 },
3934 );
3935
3936 app.apply(Action::OpenSelected);
3937
3938 assert!(matches!(
3939 app.body_view_state,
3940 BodyViewState::Ready {
3941 ref raw,
3942 ref rendered,
3943 source: BodySource::Plain,
3944 } if raw == "Plain body" && rendered == "Plain body"
3945 ));
3946 }
3947
3948 #[test]
3949 fn cached_html_only_body_resolves_ready_state() {
3950 let mut app = App::new();
3951 app.envelopes = make_test_envelopes(1);
3952 app.all_envelopes = app.envelopes.clone();
3953 let env = app.envelopes[0].clone();
3954
3955 app.body_cache.insert(
3956 env.id.clone(),
3957 MessageBody {
3958 message_id: env.id.clone(),
3959 text_plain: None,
3960 text_html: Some("<p>Hello html</p>".into()),
3961 attachments: vec![],
3962 fetched_at: chrono::Utc::now(),
3963 metadata: Default::default(),
3964 },
3965 );
3966
3967 app.apply(Action::OpenSelected);
3968
3969 assert!(matches!(
3970 app.body_view_state,
3971 BodyViewState::Ready {
3972 ref raw,
3973 ref rendered,
3974 source: BodySource::Html,
3975 } if raw == "<p>Hello html</p>"
3976 && rendered.contains("Hello html")
3977 && !rendered.contains("<p>")
3978 ));
3979 }
3980
3981 #[test]
3982 fn cached_empty_body_resolves_empty_not_loading() {
3983 let mut app = App::new();
3984 app.envelopes = make_test_envelopes(1);
3985 app.all_envelopes = app.envelopes.clone();
3986 let env = app.envelopes[0].clone();
3987
3988 app.body_cache.insert(
3989 env.id.clone(),
3990 MessageBody {
3991 message_id: env.id.clone(),
3992 text_plain: None,
3993 text_html: None,
3994 attachments: vec![],
3995 fetched_at: chrono::Utc::now(),
3996 metadata: Default::default(),
3997 },
3998 );
3999
4000 app.apply(Action::OpenSelected);
4001
4002 assert!(matches!(
4003 app.body_view_state,
4004 BodyViewState::Empty { ref preview }
4005 if preview.as_deref() == Some("Snippet 0")
4006 ));
4007 }
4008
4009 #[test]
4010 fn body_fetch_error_resolves_error_not_loading() {
4011 let mut app = App::new();
4012 app.envelopes = make_test_envelopes(1);
4013 app.all_envelopes = app.envelopes.clone();
4014 app.apply(Action::OpenSelected);
4015 let env = app.envelopes[0].clone();
4016
4017 app.resolve_body_fetch_error(&env.id, "boom".into());
4018
4019 assert!(matches!(
4020 app.body_view_state,
4021 BodyViewState::Error { ref message, ref preview }
4022 if message == "boom" && preview.as_deref() == Some("Snippet 0")
4023 ));
4024 assert!(!app.in_flight_body_requests.contains(&env.id));
4025 }
4026
4027 #[test]
4028 fn stale_body_response_does_not_clobber_current_view() {
4029 let mut app = App::new();
4030 app.envelopes = make_test_envelopes(2);
4031 app.all_envelopes = app.envelopes.clone();
4032
4033 app.apply(Action::OpenSelected);
4034 let first = app.envelopes[0].clone();
4035 app.active_pane = ActivePane::MailList;
4036 app.apply(Action::MoveDown);
4037 let second = app.envelopes[1].clone();
4038
4039 app.resolve_body_success(MessageBody {
4040 message_id: first.id.clone(),
4041 text_plain: Some("Old body".into()),
4042 text_html: None,
4043 attachments: vec![],
4044 fetched_at: chrono::Utc::now(),
4045 metadata: Default::default(),
4046 });
4047
4048 assert_eq!(
4049 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4050 Some(second.id)
4051 );
4052 assert!(matches!(
4053 app.body_view_state,
4054 BodyViewState::Loading { ref preview }
4055 if preview.as_deref() == Some("Snippet 1")
4056 ));
4057 }
4058
4059 #[test]
4060 fn reader_mode_toggle_shows_raw_html_when_disabled() {
4061 let mut app = App::new();
4062 app.envelopes = make_test_envelopes(1);
4063 app.all_envelopes = app.envelopes.clone();
4064 let env = app.envelopes[0].clone();
4065 app.body_cache.insert(
4066 env.id.clone(),
4067 MessageBody {
4068 message_id: env.id.clone(),
4069 text_plain: None,
4070 text_html: Some("<p>Hello html</p>".into()),
4071 attachments: vec![],
4072 fetched_at: chrono::Utc::now(),
4073 metadata: Default::default(),
4074 },
4075 );
4076
4077 app.apply(Action::OpenSelected);
4078
4079 match &app.body_view_state {
4080 BodyViewState::Ready { raw, rendered, .. } => {
4081 assert_eq!(raw, "<p>Hello html</p>");
4082 assert_ne!(rendered, raw);
4083 assert!(rendered.contains("Hello html"));
4084 }
4085 other => panic!("expected ready state, got {other:?}"),
4086 }
4087
4088 app.apply(Action::ToggleReaderMode);
4089
4090 match &app.body_view_state {
4091 BodyViewState::Ready { raw, rendered, .. } => {
4092 assert_eq!(raw, "<p>Hello html</p>");
4093 assert_eq!(rendered, raw);
4094 }
4095 other => panic!("expected ready state, got {other:?}"),
4096 }
4097
4098 app.apply(Action::ToggleReaderMode);
4099
4100 match &app.body_view_state {
4101 BodyViewState::Ready { raw, rendered, .. } => {
4102 assert_eq!(raw, "<p>Hello html</p>");
4103 assert_ne!(rendered, raw);
4104 assert!(rendered.contains("Hello html"));
4105 }
4106 other => panic!("expected ready state, got {other:?}"),
4107 }
4108 }
4109}