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