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