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 let selected_sidebar = app.selected_sidebar_key();
1317 app.labels = labels;
1318 app.restore_sidebar_selection(selected_sidebar);
1319 app.resolve_desired_system_mailbox();
1320 }
1321 AsyncResult::Labels(Err(e)) => {
1322 app.status_message = Some(format!("Label refresh failed: {e}"));
1323 }
1324 AsyncResult::AllEnvelopes(Ok(envelopes)) => {
1325 apply_all_envelopes_refresh(&mut app, envelopes);
1326 }
1327 AsyncResult::AllEnvelopes(Err(e)) => {
1328 app.status_message =
1329 Some(format!("Mailbox refresh failed: {e}"));
1330 }
1331 AsyncResult::AccountOperation(Ok(result)) => {
1332 app.accounts_page.status = Some(result.summary.clone());
1333 app.accounts_page.last_result = Some(result.clone());
1334 app.accounts_page.form.last_result = Some(result.clone());
1335 app.accounts_page.form.gmail_authorized = result
1336 .auth
1337 .as_ref()
1338 .map(|step| step.ok)
1339 .unwrap_or(app.accounts_page.form.gmail_authorized);
1340 if result.save.as_ref().is_some_and(|step| step.ok) {
1341 app.accounts_page.form.visible = false;
1342 }
1343 app.accounts_page.refresh_pending = true;
1344 }
1345 AsyncResult::AccountOperation(Err(e)) => {
1346 app.accounts_page.status = Some(format!("Account error: {e}"));
1347 }
1348 AsyncResult::BugReport(Ok(content)) => {
1349 let filename = format!(
1350 "mxr-bug-report-{}.md",
1351 chrono::Utc::now().format("%Y%m%d-%H%M%S")
1352 );
1353 let path = std::env::temp_dir().join(filename);
1354 match std::fs::write(&path, &content) {
1355 Ok(()) => {
1356 app.diagnostics_page.status =
1357 Some(format!("Bug report saved to {}", path.display()));
1358 }
1359 Err(e) => {
1360 app.diagnostics_page.status =
1361 Some(format!("Bug report write failed: {e}"));
1362 }
1363 }
1364 }
1365 AsyncResult::BugReport(Err(e)) => {
1366 app.diagnostics_page.status = Some(format!("Bug report error: {e}"));
1367 }
1368 AsyncResult::AttachmentFile {
1369 operation,
1370 result: Ok(file),
1371 } => {
1372 app.resolve_attachment_file(&file);
1373 let action = match operation {
1374 AttachmentOperation::Open => "Opened",
1375 AttachmentOperation::Download => "Downloaded",
1376 };
1377 let message = format!("{action} {} -> {}", file.filename, file.path);
1378 app.attachment_panel.status = Some(message.clone());
1379 app.status_message = Some(message);
1380 }
1381 AsyncResult::AttachmentFile {
1382 result: Err(e), ..
1383 } => {
1384 let message = format!("Attachment error: {e}");
1385 app.attachment_panel.status = Some(message.clone());
1386 app.status_message = Some(message);
1387 }
1388 AsyncResult::LabelEnvelopes(Ok(envelopes)) => {
1389 let selected_id =
1390 app.selected_mail_row().map(|row| row.representative.id);
1391 app.envelopes = envelopes;
1392 app.active_label = app.pending_active_label.take();
1393 restore_mail_list_selection(&mut app, selected_id);
1394 app.queue_body_window();
1395 }
1396 AsyncResult::LabelEnvelopes(Err(e)) => {
1397 app.pending_active_label = None;
1398 app.status_message = Some(format!("Label filter failed: {e}"));
1399 }
1400 AsyncResult::Bodies { requested, result: Ok(bodies) } => {
1401 let mut returned = std::collections::HashSet::new();
1402 for body in bodies {
1403 returned.insert(body.message_id.clone());
1404 app.resolve_body_success(body);
1405 }
1406 for message_id in requested {
1407 if !returned.contains(&message_id) {
1408 app.resolve_body_fetch_error(
1409 &message_id,
1410 "body not available".into(),
1411 );
1412 }
1413 }
1414 }
1415 AsyncResult::Bodies { requested, result: Err(e) } => {
1416 let message = e.to_string();
1417 for message_id in requested {
1418 app.resolve_body_fetch_error(&message_id, message.clone());
1419 }
1420 }
1421 AsyncResult::Thread {
1422 thread_id,
1423 result: Ok((thread, messages)),
1424 } => {
1425 app.resolve_thread_success(thread, messages);
1426 let _ = thread_id;
1427 }
1428 AsyncResult::Thread {
1429 thread_id,
1430 result: Err(_),
1431 } => {
1432 app.resolve_thread_fetch_error(&thread_id);
1433 }
1434 AsyncResult::MutationResult(Ok(effect)) => {
1435 app.finish_pending_mutation();
1436 let show_completion_status = app.pending_mutation_count == 0;
1437 match effect {
1438 app::MutationEffect::RemoveFromList(id) => {
1439 app.apply_removed_message_ids(std::slice::from_ref(&id));
1440 if show_completion_status {
1441 app.status_message = Some("Done".into());
1442 }
1443 app.pending_subscriptions_refresh = true;
1444 }
1445 app::MutationEffect::RemoveFromListMany(ids) => {
1446 app.apply_removed_message_ids(&ids);
1447 if show_completion_status {
1448 app.status_message = Some("Done".into());
1449 }
1450 app.pending_subscriptions_refresh = true;
1451 }
1452 app::MutationEffect::UpdateFlags { message_id, flags } => {
1453 app.apply_local_flags(&message_id, flags);
1454 if show_completion_status {
1455 app.status_message = Some("Done".into());
1456 }
1457 }
1458 app::MutationEffect::UpdateFlagsMany { updates } => {
1459 app.apply_local_flags_many(&updates);
1460 if show_completion_status {
1461 app.status_message = Some("Done".into());
1462 }
1463 }
1464 app::MutationEffect::RefreshList => {
1465 if let Some(label_id) = app.active_label.clone() {
1466 app.pending_label_fetch = Some(label_id);
1467 }
1468 app.pending_subscriptions_refresh = true;
1469 if show_completion_status {
1470 app.status_message = Some("Synced".into());
1471 }
1472 }
1473 app::MutationEffect::ModifyLabels {
1474 message_ids,
1475 add,
1476 remove,
1477 status,
1478 } => {
1479 app.apply_local_label_refs(&message_ids, &add, &remove);
1480 if show_completion_status {
1481 app.status_message = Some(status);
1482 }
1483 }
1484 app::MutationEffect::StatusOnly(msg) => {
1485 if show_completion_status {
1486 app.status_message = Some(msg);
1487 }
1488 }
1489 }
1490 }
1491 AsyncResult::MutationResult(Err(e)) => {
1492 app.finish_pending_mutation();
1493 app.refresh_mailbox_after_mutation_failure();
1494 app.show_mutation_failure(&e);
1495 }
1496 AsyncResult::ComposeReady(Ok(data)) => {
1497 ratatui::restore();
1499 let editor = mxr_compose::editor::resolve_editor(None);
1500 let status = std::process::Command::new(&editor)
1501 .arg(format!("+{}", data.cursor_line))
1502 .arg(&data.draft_path)
1503 .status();
1504 terminal = ratatui::init();
1505 match status {
1506 Ok(s) if s.success() => {
1507 match pending_send_from_edited_draft(&data) {
1508 Ok(Some(pending)) => {
1509 app.pending_send_confirm = Some(pending);
1510 }
1511 Ok(None) => {}
1512 Err(message) => {
1513 app.status_message = Some(message);
1514 }
1515 }
1516 }
1517 Ok(_) => {
1518 app.status_message = Some("Draft discarded".into());
1520 let _ = std::fs::remove_file(&data.draft_path);
1521 }
1522 Err(e) => {
1523 app.status_message =
1524 Some(format!("Failed to launch editor: {e}"));
1525 }
1526 }
1527 }
1528 AsyncResult::ComposeReady(Err(e)) => {
1529 app.status_message = Some(format!("Compose error: {e}"));
1530 }
1531 AsyncResult::ExportResult(Ok(msg)) => {
1532 app.status_message = Some(msg);
1533 }
1534 AsyncResult::ExportResult(Err(e)) => {
1535 app.status_message = Some(format!("Export failed: {e}"));
1536 }
1537 AsyncResult::Unsubscribe(Ok(result)) => {
1538 if !result.archived_ids.is_empty() {
1539 app.apply_removed_message_ids(&result.archived_ids);
1540 }
1541 app.status_message = Some(result.message);
1542 app.pending_subscriptions_refresh = true;
1543 }
1544 AsyncResult::Unsubscribe(Err(e)) => {
1545 app.status_message = Some(format!("Unsubscribe failed: {e}"));
1546 }
1547 AsyncResult::Subscriptions(Ok(subscriptions)) => {
1548 app.set_subscriptions(subscriptions);
1549 }
1550 AsyncResult::Subscriptions(Err(e)) => {
1551 app.status_message = Some(format!("Subscriptions error: {e}"));
1552 }
1553 AsyncResult::DaemonEvent(event) => handle_daemon_event(&mut app, event),
1554 }
1555 }
1556 }
1557 _ = tokio::time::sleep(timeout) => {
1558 app.tick();
1559 }
1560 }
1561
1562 if app.should_quit {
1563 break;
1564 }
1565 }
1566
1567 ratatui::restore();
1568 Ok(())
1569}
1570
1571enum AsyncResult {
1572 Search {
1573 target: app::SearchTarget,
1574 append: bool,
1575 session_id: u64,
1576 result: Result<SearchResultData, MxrError>,
1577 },
1578 Rules(Result<Vec<serde_json::Value>, MxrError>),
1579 RuleDetail(Result<serde_json::Value, MxrError>),
1580 RuleHistory(Result<Vec<serde_json::Value>, MxrError>),
1581 RuleDryRun(Result<Vec<serde_json::Value>, MxrError>),
1582 RuleForm(Result<mxr_protocol::RuleFormData, MxrError>),
1583 RuleDeleted(Result<(), MxrError>),
1584 RuleUpsert(Result<serde_json::Value, MxrError>),
1585 Diagnostics(Box<Result<Response, MxrError>>),
1586 Status(Result<StatusSnapshot, MxrError>),
1587 Accounts(Result<Vec<mxr_protocol::AccountSummaryData>, MxrError>),
1588 Labels(Result<Vec<mxr_core::Label>, MxrError>),
1589 AllEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1590 Subscriptions(Result<Vec<mxr_core::types::SubscriptionSummary>, MxrError>),
1591 AccountOperation(Result<mxr_protocol::AccountOperationResult, MxrError>),
1592 BugReport(Result<String, MxrError>),
1593 AttachmentFile {
1594 operation: AttachmentOperation,
1595 result: Result<mxr_protocol::AttachmentFile, MxrError>,
1596 },
1597 LabelEnvelopes(Result<Vec<mxr_core::Envelope>, MxrError>),
1598 Bodies {
1599 requested: Vec<mxr_core::MessageId>,
1600 result: Result<Vec<mxr_core::MessageBody>, MxrError>,
1601 },
1602 Thread {
1603 thread_id: mxr_core::ThreadId,
1604 result: Result<(mxr_core::Thread, Vec<mxr_core::Envelope>), MxrError>,
1605 },
1606 MutationResult(Result<app::MutationEffect, MxrError>),
1607 ComposeReady(Result<ComposeReadyData, MxrError>),
1608 ExportResult(Result<String, MxrError>),
1609 Unsubscribe(Result<UnsubscribeResultData, MxrError>),
1610 DaemonEvent(DaemonEvent),
1611}
1612
1613struct ComposeReadyData {
1614 draft_path: std::path::PathBuf,
1615 cursor_line: usize,
1616 initial_content: String,
1617}
1618
1619struct SearchResultData {
1620 envelopes: Vec<mxr_core::types::Envelope>,
1621 scores: std::collections::HashMap<mxr_core::MessageId, f32>,
1622 has_more: bool,
1623}
1624
1625struct StatusSnapshot {
1626 uptime_secs: u64,
1627 daemon_pid: Option<u32>,
1628 accounts: Vec<String>,
1629 total_messages: u32,
1630 sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
1631}
1632
1633struct UnsubscribeResultData {
1634 archived_ids: Vec<mxr_core::MessageId>,
1635 message: String,
1636}
1637
1638async fn handle_compose_action(
1639 bg: &mpsc::UnboundedSender<IpcRequest>,
1640 action: ComposeAction,
1641) -> Result<ComposeReadyData, MxrError> {
1642 let from = get_account_email(bg).await?;
1643
1644 let kind = match action {
1645 ComposeAction::EditDraft(path) => {
1646 let cursor_line = 1;
1648 return Ok(ComposeReadyData {
1649 draft_path: path.clone(),
1650 cursor_line,
1651 initial_content: std::fs::read_to_string(&path)
1652 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1653 });
1654 }
1655 ComposeAction::New => mxr_compose::ComposeKind::New,
1656 ComposeAction::NewWithTo(to) => mxr_compose::ComposeKind::NewWithTo { to },
1657 ComposeAction::Reply { message_id } => {
1658 let resp = ipc_call(
1659 bg,
1660 Request::PrepareReply {
1661 message_id,
1662 reply_all: false,
1663 },
1664 )
1665 .await?;
1666 match resp {
1667 Response::Ok {
1668 data: ResponseData::ReplyContext { context },
1669 } => mxr_compose::ComposeKind::Reply {
1670 in_reply_to: context.in_reply_to,
1671 references: context.references,
1672 to: context.reply_to,
1673 cc: context.cc,
1674 subject: context.subject,
1675 thread_context: context.thread_context,
1676 },
1677 Response::Error { message } => return Err(MxrError::Ipc(message)),
1678 _ => return Err(MxrError::Ipc("unexpected response".into())),
1679 }
1680 }
1681 ComposeAction::ReplyAll { message_id } => {
1682 let resp = ipc_call(
1683 bg,
1684 Request::PrepareReply {
1685 message_id,
1686 reply_all: true,
1687 },
1688 )
1689 .await?;
1690 match resp {
1691 Response::Ok {
1692 data: ResponseData::ReplyContext { context },
1693 } => mxr_compose::ComposeKind::Reply {
1694 in_reply_to: context.in_reply_to,
1695 references: context.references,
1696 to: context.reply_to,
1697 cc: context.cc,
1698 subject: context.subject,
1699 thread_context: context.thread_context,
1700 },
1701 Response::Error { message } => return Err(MxrError::Ipc(message)),
1702 _ => return Err(MxrError::Ipc("unexpected response".into())),
1703 }
1704 }
1705 ComposeAction::Forward { message_id } => {
1706 let resp = ipc_call(bg, Request::PrepareForward { message_id }).await?;
1707 match resp {
1708 Response::Ok {
1709 data: ResponseData::ForwardContext { context },
1710 } => mxr_compose::ComposeKind::Forward {
1711 subject: context.subject,
1712 original_context: context.forwarded_content,
1713 },
1714 Response::Error { message } => return Err(MxrError::Ipc(message)),
1715 _ => return Err(MxrError::Ipc("unexpected response".into())),
1716 }
1717 }
1718 };
1719
1720 let (path, cursor_line) =
1721 mxr_compose::create_draft_file(kind, &from).map_err(|e| MxrError::Ipc(e.to_string()))?;
1722
1723 Ok(ComposeReadyData {
1724 draft_path: path.clone(),
1725 cursor_line,
1726 initial_content: std::fs::read_to_string(&path)
1727 .map_err(|e| MxrError::Ipc(e.to_string()))?,
1728 })
1729}
1730
1731async fn get_account_email(bg: &mpsc::UnboundedSender<IpcRequest>) -> Result<String, MxrError> {
1732 let resp = ipc_call(bg, Request::ListAccounts).await?;
1733 match resp {
1734 Response::Ok {
1735 data: ResponseData::Accounts { mut accounts },
1736 } => {
1737 if let Some(index) = accounts.iter().position(|account| account.is_default) {
1738 Ok(accounts.remove(index).email)
1739 } else {
1740 accounts
1741 .into_iter()
1742 .next()
1743 .map(|account| account.email)
1744 .ok_or_else(|| MxrError::Ipc("No runtime account configured".into()))
1745 }
1746 }
1747 Response::Error { message } => Err(MxrError::Ipc(message)),
1748 _ => Err(MxrError::Ipc("Unexpected account response".into())),
1749 }
1750}
1751
1752fn pending_send_from_edited_draft(data: &ComposeReadyData) -> Result<Option<PendingSend>, String> {
1753 let content = std::fs::read_to_string(&data.draft_path)
1754 .map_err(|e| format!("Failed to read draft: {e}"))?;
1755 let unchanged = content == data.initial_content;
1756
1757 let (fm, body) = mxr_compose::frontmatter::parse_compose_file(&content)
1758 .map_err(|e| format!("Parse error: {e}"))?;
1759 let issues = mxr_compose::validate_draft(&fm, &body);
1760 let has_errors = issues.iter().any(|issue| issue.is_error());
1761 if has_errors {
1762 let msgs: Vec<String> = issues.iter().map(|issue| issue.to_string()).collect();
1763 return Err(format!("Draft errors: {}", msgs.join("; ")));
1764 }
1765
1766 Ok(Some(PendingSend {
1767 fm,
1768 body,
1769 draft_path: data.draft_path.clone(),
1770 allow_send: !unchanged,
1771 }))
1772}
1773
1774fn daemon_socket_path() -> std::path::PathBuf {
1775 config_socket_path()
1776}
1777
1778async fn request_account_operation(
1779 bg: &mpsc::UnboundedSender<IpcRequest>,
1780 request: Request,
1781) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1782 let resp = ipc_call(bg, request).await;
1783 match resp {
1784 Ok(Response::Ok {
1785 data: ResponseData::AccountOperation { result },
1786 }) => Ok(result),
1787 Ok(Response::Error { message }) => Err(MxrError::Ipc(message)),
1788 Err(e) => Err(e),
1789 _ => Err(MxrError::Ipc("unexpected response".into())),
1790 }
1791}
1792
1793async fn run_account_save_workflow(
1794 bg: &mpsc::UnboundedSender<IpcRequest>,
1795 account: mxr_protocol::AccountConfigData,
1796) -> Result<mxr_protocol::AccountOperationResult, MxrError> {
1797 let mut result = if matches!(
1798 account.sync,
1799 Some(mxr_protocol::AccountSyncConfigData::Gmail { .. })
1800 ) {
1801 request_account_operation(
1802 bg,
1803 Request::AuthorizeAccountConfig {
1804 account: account.clone(),
1805 reauthorize: false,
1806 },
1807 )
1808 .await?
1809 } else {
1810 empty_account_operation_result()
1811 };
1812
1813 if result.auth.as_ref().is_some_and(|step| !step.ok) {
1814 return Ok(result);
1815 }
1816
1817 let save_result = request_account_operation(
1818 bg,
1819 Request::UpsertAccountConfig {
1820 account: account.clone(),
1821 },
1822 )
1823 .await?;
1824 merge_account_operation_result(&mut result, save_result);
1825
1826 if result.save.as_ref().is_some_and(|step| !step.ok) {
1827 return Ok(result);
1828 }
1829
1830 let test_result = request_account_operation(bg, Request::TestAccountConfig { account }).await?;
1831 merge_account_operation_result(&mut result, test_result);
1832
1833 Ok(result)
1834}
1835
1836fn empty_account_operation_result() -> mxr_protocol::AccountOperationResult {
1837 mxr_protocol::AccountOperationResult {
1838 ok: true,
1839 summary: String::new(),
1840 save: None,
1841 auth: None,
1842 sync: None,
1843 send: None,
1844 }
1845}
1846
1847fn merge_account_operation_result(
1848 base: &mut mxr_protocol::AccountOperationResult,
1849 next: mxr_protocol::AccountOperationResult,
1850) {
1851 base.ok &= next.ok;
1852 if !next.summary.is_empty() {
1853 if base.summary.is_empty() {
1854 base.summary = next.summary;
1855 } else {
1856 base.summary = format!("{} | {}", base.summary, next.summary);
1857 }
1858 }
1859 if next.save.is_some() {
1860 base.save = next.save;
1861 }
1862 if next.auth.is_some() {
1863 base.auth = next.auth;
1864 }
1865 if next.sync.is_some() {
1866 base.sync = next.sync;
1867 }
1868 if next.send.is_some() {
1869 base.send = next.send;
1870 }
1871}
1872
1873fn handle_daemon_event(app: &mut App, event: DaemonEvent) {
1874 match event {
1875 DaemonEvent::SyncCompleted {
1876 messages_synced, ..
1877 } => {
1878 app.pending_labels_refresh = true;
1879 app.pending_all_envelopes_refresh = true;
1880 app.pending_subscriptions_refresh = true;
1881 app.pending_status_refresh = true;
1882 if let Some(label_id) = app.active_label.clone() {
1883 app.pending_label_fetch = Some(label_id);
1884 }
1885 if messages_synced > 0 {
1886 app.status_message = Some(format!("Synced {messages_synced} messages"));
1887 }
1888 }
1889 DaemonEvent::LabelCountsUpdated { counts } => {
1890 let selected_sidebar = app.selected_sidebar_key();
1891 for count in &counts {
1892 if let Some(label) = app
1893 .labels
1894 .iter_mut()
1895 .find(|label| label.id == count.label_id)
1896 {
1897 label.unread_count = count.unread_count;
1898 label.total_count = count.total_count;
1899 }
1900 }
1901 app.restore_sidebar_selection(selected_sidebar);
1902 }
1903 _ => {}
1904 }
1905}
1906
1907fn apply_all_envelopes_refresh(app: &mut App, envelopes: Vec<mxr_core::Envelope>) {
1908 let selected_id = (app.active_label.is_none()
1909 && app.pending_active_label.is_none()
1910 && !app.search_active
1911 && app.mailbox_view == app::MailboxView::Messages)
1912 .then(|| app.selected_mail_row().map(|row| row.representative.id))
1913 .flatten();
1914 app.all_envelopes = envelopes;
1915 if app.active_label.is_none() && app.pending_active_label.is_none() && !app.search_active {
1916 app.envelopes = app
1917 .all_envelopes
1918 .iter()
1919 .filter(|envelope| !envelope.flags.contains(mxr_core::MessageFlags::TRASH))
1920 .cloned()
1921 .collect();
1922 if app.mailbox_view == app::MailboxView::Messages {
1923 restore_mail_list_selection(app, selected_id);
1924 } else {
1925 app.selected_index = app
1926 .selected_index
1927 .min(app.subscriptions_page.entries.len().saturating_sub(1));
1928 }
1929 app.queue_body_window();
1930 }
1931}
1932
1933fn restore_mail_list_selection(app: &mut App, selected_id: Option<mxr_core::MessageId>) {
1934 let row_count = app.mail_list_rows().len();
1935 if row_count == 0 {
1936 app.selected_index = 0;
1937 app.scroll_offset = 0;
1938 return;
1939 }
1940
1941 if let Some(id) = selected_id {
1942 if let Some(position) = app
1943 .mail_list_rows()
1944 .iter()
1945 .position(|row| row.representative.id == id)
1946 {
1947 app.selected_index = position;
1948 } else {
1949 app.selected_index = app.selected_index.min(row_count.saturating_sub(1));
1950 }
1951 } else {
1952 app.selected_index = 0;
1953 }
1954
1955 let visible_height = app.visible_height.max(1);
1956 if app.selected_index < app.scroll_offset {
1957 app.scroll_offset = app.selected_index;
1958 } else if app.selected_index >= app.scroll_offset + visible_height {
1959 app.scroll_offset = app.selected_index + 1 - visible_height;
1960 }
1961}
1962
1963async fn load_accounts_page_accounts(
1964 bg: &mpsc::UnboundedSender<IpcRequest>,
1965) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1966 match ipc_call(bg, Request::ListAccounts).await {
1967 Ok(Response::Ok {
1968 data: ResponseData::Accounts { accounts },
1969 }) if !accounts.is_empty() => Ok(accounts),
1970 Ok(Response::Ok {
1971 data: ResponseData::Accounts { .. },
1972 })
1973 | Ok(Response::Error { .. })
1974 | Err(_) => load_config_account_summaries(bg).await,
1975 Ok(_) => Err(MxrError::Ipc("unexpected response".into())),
1976 }
1977}
1978
1979async fn load_config_account_summaries(
1980 bg: &mpsc::UnboundedSender<IpcRequest>,
1981) -> Result<Vec<mxr_protocol::AccountSummaryData>, MxrError> {
1982 let resp = ipc_call(bg, Request::ListAccountsConfig).await?;
1983 match resp {
1984 Response::Ok {
1985 data: ResponseData::AccountsConfig { accounts },
1986 } => Ok(accounts
1987 .into_iter()
1988 .map(account_config_to_summary)
1989 .collect()),
1990 Response::Error { message } => Err(MxrError::Ipc(message)),
1991 _ => Err(MxrError::Ipc("unexpected response".into())),
1992 }
1993}
1994
1995fn account_config_to_summary(
1996 account: mxr_protocol::AccountConfigData,
1997) -> mxr_protocol::AccountSummaryData {
1998 let provider_kind = account
1999 .sync
2000 .as_ref()
2001 .map(account_sync_kind_label)
2002 .or_else(|| account.send.as_ref().map(account_send_kind_label))
2003 .unwrap_or_else(|| "unknown".to_string());
2004 let account_id = mxr_core::AccountId::from_provider_id(&provider_kind, &account.email);
2005
2006 mxr_protocol::AccountSummaryData {
2007 account_id,
2008 key: Some(account.key),
2009 name: account.name,
2010 email: account.email,
2011 provider_kind,
2012 sync_kind: account.sync.as_ref().map(account_sync_kind_label),
2013 send_kind: account.send.as_ref().map(account_send_kind_label),
2014 enabled: true,
2015 is_default: account.is_default,
2016 source: mxr_protocol::AccountSourceData::Config,
2017 editable: mxr_protocol::AccountEditModeData::Full,
2018 sync: account.sync,
2019 send: account.send,
2020 }
2021}
2022
2023fn account_sync_kind_label(sync: &mxr_protocol::AccountSyncConfigData) -> String {
2024 match sync {
2025 mxr_protocol::AccountSyncConfigData::Gmail { .. } => "gmail".to_string(),
2026 mxr_protocol::AccountSyncConfigData::Imap { .. } => "imap".to_string(),
2027 }
2028}
2029
2030fn account_send_kind_label(send: &mxr_protocol::AccountSendConfigData) -> String {
2031 match send {
2032 mxr_protocol::AccountSendConfigData::Gmail => "gmail".to_string(),
2033 mxr_protocol::AccountSendConfigData::Smtp { .. } => "smtp".to_string(),
2034 }
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039 use super::action::Action;
2040 use super::app::{
2041 ActivePane, App, BodySource, BodyViewState, LayoutMode, MutationEffect,
2042 PendingSearchRequest, Screen, SearchPane, SearchTarget, SidebarItem, SEARCH_PAGE_SIZE,
2043 };
2044 use super::input::InputHandler;
2045 use super::ui::command_palette::default_commands;
2046 use super::ui::command_palette::CommandPalette;
2047 use super::ui::search_bar::SearchBar;
2048 use super::ui::status_bar;
2049 use super::{
2050 app::MailListMode, apply_all_envelopes_refresh, handle_daemon_event,
2051 pending_send_from_edited_draft, ComposeReadyData, PendingSend,
2052 };
2053 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2054 use mxr_config::RenderConfig;
2055 use mxr_core::id::*;
2056 use mxr_core::types::*;
2057 use mxr_core::MxrError;
2058 use mxr_protocol::{DaemonEvent, LabelCount, MutationCommand, Request};
2059
2060 fn make_test_envelopes(count: usize) -> Vec<Envelope> {
2061 (0..count)
2062 .map(|i| Envelope {
2063 id: MessageId::new(),
2064 account_id: AccountId::new(),
2065 provider_id: format!("fake-{}", i),
2066 thread_id: ThreadId::new(),
2067 message_id_header: None,
2068 in_reply_to: None,
2069 references: vec![],
2070 from: Address {
2071 name: Some(format!("User {}", i)),
2072 email: format!("user{}@example.com", i),
2073 },
2074 to: vec![],
2075 cc: vec![],
2076 bcc: vec![],
2077 subject: format!("Subject {}", i),
2078 date: chrono::Utc::now(),
2079 flags: if i % 2 == 0 {
2080 MessageFlags::READ
2081 } else {
2082 MessageFlags::empty()
2083 },
2084 snippet: format!("Snippet {}", i),
2085 has_attachments: false,
2086 size_bytes: 1000,
2087 unsubscribe: UnsubscribeMethod::None,
2088 label_provider_ids: vec![],
2089 })
2090 .collect()
2091 }
2092
2093 fn make_unsubscribe_envelope(
2094 account_id: AccountId,
2095 sender_email: &str,
2096 unsubscribe: UnsubscribeMethod,
2097 ) -> Envelope {
2098 Envelope {
2099 id: MessageId::new(),
2100 account_id,
2101 provider_id: "unsub-fixture".into(),
2102 thread_id: ThreadId::new(),
2103 message_id_header: None,
2104 in_reply_to: None,
2105 references: vec![],
2106 from: Address {
2107 name: Some("Newsletter".into()),
2108 email: sender_email.into(),
2109 },
2110 to: vec![],
2111 cc: vec![],
2112 bcc: vec![],
2113 subject: "Newsletter".into(),
2114 date: chrono::Utc::now(),
2115 flags: MessageFlags::empty(),
2116 snippet: "newsletter".into(),
2117 has_attachments: false,
2118 size_bytes: 42,
2119 unsubscribe,
2120 label_provider_ids: vec![],
2121 }
2122 }
2123
2124 #[test]
2125 fn input_j_moves_down() {
2126 let mut h = InputHandler::new();
2127 assert_eq!(
2128 h.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)),
2129 Some(Action::MoveDown)
2130 );
2131 }
2132
2133 #[test]
2134 fn input_k_moves_up() {
2135 let mut h = InputHandler::new();
2136 assert_eq!(
2137 h.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
2138 Some(Action::MoveUp)
2139 );
2140 }
2141
2142 #[test]
2143 fn input_gg_jumps_top() {
2144 let mut h = InputHandler::new();
2145 assert_eq!(
2146 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2147 None
2148 );
2149 assert_eq!(
2150 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2151 Some(Action::JumpTop)
2152 );
2153 }
2154
2155 #[test]
2156 fn input_zz_centers() {
2157 let mut h = InputHandler::new();
2158 assert_eq!(
2159 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2160 None
2161 );
2162 assert_eq!(
2163 h.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
2164 Some(Action::CenterCurrent)
2165 );
2166 }
2167
2168 #[test]
2169 fn input_enter_opens() {
2170 let mut h = InputHandler::new();
2171 assert_eq!(
2172 h.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2173 Some(Action::OpenSelected)
2174 );
2175 }
2176
2177 #[test]
2178 fn input_o_opens() {
2179 let mut h = InputHandler::new();
2180 assert_eq!(
2181 h.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
2182 Some(Action::OpenSelected)
2183 );
2184 }
2185
2186 #[test]
2187 fn input_escape_back() {
2188 let mut h = InputHandler::new();
2189 assert_eq!(
2190 h.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
2191 Some(Action::Back)
2192 );
2193 }
2194
2195 #[test]
2196 fn input_q_quits() {
2197 let mut h = InputHandler::new();
2198 assert_eq!(
2199 h.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
2200 Some(Action::QuitView)
2201 );
2202 }
2203
2204 #[test]
2205 fn input_hml_viewport() {
2206 let mut h = InputHandler::new();
2207 assert_eq!(
2208 h.handle_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT)),
2209 Some(Action::ViewportTop)
2210 );
2211 assert_eq!(
2212 h.handle_key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::SHIFT)),
2213 Some(Action::ViewportMiddle)
2214 );
2215 assert_eq!(
2216 h.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT)),
2217 Some(Action::ViewportBottom)
2218 );
2219 }
2220
2221 #[test]
2222 fn input_ctrl_du_page() {
2223 let mut h = InputHandler::new();
2224 assert_eq!(
2225 h.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
2226 Some(Action::PageDown)
2227 );
2228 assert_eq!(
2229 h.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)),
2230 Some(Action::PageUp)
2231 );
2232 }
2233
2234 #[test]
2235 fn app_move_down() {
2236 let mut app = App::new();
2237 app.envelopes = make_test_envelopes(5);
2238 app.apply(Action::MoveDown);
2239 assert_eq!(app.selected_index, 1);
2240 }
2241
2242 #[test]
2243 fn app_move_up_at_zero() {
2244 let mut app = App::new();
2245 app.envelopes = make_test_envelopes(5);
2246 app.apply(Action::MoveUp);
2247 assert_eq!(app.selected_index, 0);
2248 }
2249
2250 #[test]
2251 fn app_jump_top() {
2252 let mut app = App::new();
2253 app.envelopes = make_test_envelopes(10);
2254 app.selected_index = 5;
2255 app.apply(Action::JumpTop);
2256 assert_eq!(app.selected_index, 0);
2257 }
2258
2259 #[test]
2260 fn app_switch_pane() {
2261 let mut app = App::new();
2262 assert_eq!(app.active_pane, ActivePane::MailList);
2263 app.apply(Action::SwitchPane);
2264 assert_eq!(app.active_pane, ActivePane::Sidebar);
2265 app.apply(Action::SwitchPane);
2266 assert_eq!(app.active_pane, ActivePane::MailList);
2267 }
2268
2269 #[test]
2270 fn app_quit() {
2271 let mut app = App::new();
2272 app.apply(Action::QuitView);
2273 assert!(app.should_quit);
2274 }
2275
2276 #[test]
2277 fn app_new_uses_default_reader_mode() {
2278 let app = App::new();
2279 assert!(app.reader_mode);
2280 }
2281
2282 #[test]
2283 fn app_from_render_config_respects_reader_mode() {
2284 let config = RenderConfig {
2285 reader_mode: false,
2286 ..Default::default()
2287 };
2288 let app = App::from_render_config(&config);
2289 assert!(!app.reader_mode);
2290 }
2291
2292 #[test]
2293 fn apply_runtime_config_updates_tui_settings() {
2294 let mut app = App::new();
2295 let mut config = mxr_config::MxrConfig::default();
2296 config.render.reader_mode = false;
2297 config.snooze.morning_hour = 7;
2298 config.appearance.theme = "light".into();
2299
2300 app.apply_runtime_config(&config);
2301
2302 assert!(!app.reader_mode);
2303 assert_eq!(app.snooze_config.morning_hour, 7);
2304 assert_eq!(
2305 app.theme.selection_fg,
2306 crate::theme::Theme::light().selection_fg
2307 );
2308 }
2309
2310 #[test]
2311 fn edit_config_action_sets_pending_flag() {
2312 let mut app = App::new();
2313
2314 app.apply(Action::EditConfig);
2315
2316 assert!(app.pending_config_edit);
2317 assert_eq!(
2318 app.status_message.as_deref(),
2319 Some("Opening config in editor...")
2320 );
2321 }
2322
2323 #[test]
2324 fn open_logs_action_sets_pending_flag() {
2325 let mut app = App::new();
2326
2327 app.apply(Action::OpenLogs);
2328
2329 assert!(app.pending_log_open);
2330 assert_eq!(
2331 app.status_message.as_deref(),
2332 Some("Opening log file in editor...")
2333 );
2334 }
2335
2336 #[test]
2337 fn app_move_down_bounds() {
2338 let mut app = App::new();
2339 app.envelopes = make_test_envelopes(3);
2340 app.apply(Action::MoveDown);
2341 app.apply(Action::MoveDown);
2342 app.apply(Action::MoveDown);
2343 assert_eq!(app.selected_index, 2);
2344 }
2345
2346 #[test]
2347 fn layout_mode_switching() {
2348 let mut app = App::new();
2349 app.envelopes = make_test_envelopes(3);
2350 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2351 app.apply(Action::OpenMessageView);
2352 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2353 app.apply(Action::CloseMessageView);
2354 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2355 }
2356
2357 #[test]
2358 fn command_palette_toggle() {
2359 let mut p = CommandPalette::default();
2360 assert!(!p.visible);
2361 p.toggle();
2362 assert!(p.visible);
2363 p.toggle();
2364 assert!(!p.visible);
2365 }
2366
2367 #[test]
2368 fn command_palette_fuzzy_filter() {
2369 let mut p = CommandPalette::default();
2370 p.toggle();
2371 p.on_char('i');
2372 p.on_char('n');
2373 p.on_char('b');
2374 let labels: Vec<&str> = p
2375 .filtered
2376 .iter()
2377 .map(|&i| p.commands[i].label.as_str())
2378 .collect();
2379 assert!(labels.contains(&"Go to Inbox"));
2380 }
2381
2382 #[test]
2383 fn command_palette_shortcut_filter_finds_edit_config() {
2384 let mut p = CommandPalette::default();
2385 p.toggle();
2386 p.on_char('g');
2387 p.on_char('c');
2388 let labels: Vec<&str> = p
2389 .filtered
2390 .iter()
2391 .map(|&i| p.commands[i].label.as_str())
2392 .collect();
2393 assert!(labels.contains(&"Edit Config"));
2394 }
2395
2396 #[test]
2397 fn unsubscribe_opens_confirm_modal_and_scopes_archive_to_sender_and_account() {
2398 let mut app = App::new();
2399 let account_id = AccountId::new();
2400 let other_account_id = AccountId::new();
2401 let target = make_unsubscribe_envelope(
2402 account_id.clone(),
2403 "news@example.com",
2404 UnsubscribeMethod::HttpLink {
2405 url: "https://example.com/unsub".into(),
2406 },
2407 );
2408 let same_sender_same_account = make_unsubscribe_envelope(
2409 account_id.clone(),
2410 "news@example.com",
2411 UnsubscribeMethod::None,
2412 );
2413 let same_sender_other_account = make_unsubscribe_envelope(
2414 other_account_id,
2415 "news@example.com",
2416 UnsubscribeMethod::None,
2417 );
2418 let different_sender_same_account =
2419 make_unsubscribe_envelope(account_id, "other@example.com", UnsubscribeMethod::None);
2420
2421 app.envelopes = vec![target.clone()];
2422 app.all_envelopes = vec![
2423 target.clone(),
2424 same_sender_same_account.clone(),
2425 same_sender_other_account,
2426 different_sender_same_account,
2427 ];
2428
2429 app.apply(Action::Unsubscribe);
2430
2431 let pending = app
2432 .pending_unsubscribe_confirm
2433 .as_ref()
2434 .expect("unsubscribe modal should open");
2435 assert_eq!(pending.sender_email, "news@example.com");
2436 assert_eq!(pending.method_label, "browser link");
2437 assert_eq!(pending.archive_message_ids.len(), 2);
2438 assert!(pending.archive_message_ids.contains(&target.id));
2439 assert!(pending
2440 .archive_message_ids
2441 .contains(&same_sender_same_account.id));
2442 }
2443
2444 #[test]
2445 fn unsubscribe_without_method_sets_status_error() {
2446 let mut app = App::new();
2447 let env = make_unsubscribe_envelope(
2448 AccountId::new(),
2449 "news@example.com",
2450 UnsubscribeMethod::None,
2451 );
2452 app.envelopes = vec![env];
2453
2454 app.apply(Action::Unsubscribe);
2455
2456 assert!(app.pending_unsubscribe_confirm.is_none());
2457 assert_eq!(
2458 app.status_message.as_deref(),
2459 Some("No unsubscribe option found for this message")
2460 );
2461 }
2462
2463 #[test]
2464 fn unsubscribe_confirm_archive_populates_pending_action() {
2465 let mut app = App::new();
2466 let env = make_unsubscribe_envelope(
2467 AccountId::new(),
2468 "news@example.com",
2469 UnsubscribeMethod::OneClick {
2470 url: "https://example.com/one-click".into(),
2471 },
2472 );
2473 app.envelopes = vec![env.clone()];
2474 app.all_envelopes = vec![env.clone()];
2475 app.apply(Action::Unsubscribe);
2476 app.apply(Action::ConfirmUnsubscribeAndArchiveSender);
2477
2478 let pending = app
2479 .pending_unsubscribe_action
2480 .as_ref()
2481 .expect("unsubscribe action should be queued");
2482 assert_eq!(pending.message_id, env.id);
2483 assert_eq!(pending.archive_message_ids.len(), 1);
2484 assert_eq!(pending.sender_email, "news@example.com");
2485 }
2486
2487 #[test]
2488 fn search_input_lifecycle() {
2489 let mut bar = SearchBar::default();
2490 bar.activate();
2491 assert!(bar.active);
2492 bar.on_char('h');
2493 bar.on_char('e');
2494 bar.on_char('l');
2495 bar.on_char('l');
2496 bar.on_char('o');
2497 assert_eq!(bar.query, "hello");
2498 let q = bar.submit();
2499 assert_eq!(q, "hello");
2500 assert!(!bar.active);
2501 }
2502
2503 #[test]
2504 fn search_bar_cycles_modes() {
2505 let mut bar = SearchBar::default();
2506 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2507 bar.cycle_mode();
2508 assert_eq!(bar.mode, mxr_core::SearchMode::Hybrid);
2509 bar.cycle_mode();
2510 assert_eq!(bar.mode, mxr_core::SearchMode::Semantic);
2511 bar.cycle_mode();
2512 assert_eq!(bar.mode, mxr_core::SearchMode::Lexical);
2513 }
2514
2515 #[test]
2516 fn reopening_active_search_preserves_query() {
2517 let mut app = App::new();
2518 app.search_active = true;
2519 app.search_bar.query = "deploy".to_string();
2520 app.search_bar.cursor_pos = 0;
2521
2522 app.apply(Action::OpenSearch);
2523
2524 assert!(app.search_bar.active);
2525 assert_eq!(app.search_bar.query, "deploy");
2526 assert_eq!(app.search_bar.cursor_pos, "deploy".len());
2527 }
2528
2529 #[test]
2530 fn g_prefix_navigation() {
2531 let mut h = InputHandler::new();
2532 assert_eq!(
2533 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2534 None
2535 );
2536 assert_eq!(
2537 h.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)),
2538 Some(Action::GoToInbox)
2539 );
2540 assert_eq!(
2541 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
2542 None
2543 );
2544 assert_eq!(
2545 h.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)),
2546 Some(Action::GoToStarred)
2547 );
2548 }
2549
2550 #[test]
2551 fn status_bar_sync_formats() {
2552 assert_eq!(
2553 status_bar::format_sync_status(12, Some("synced 2m ago")),
2554 "[INBOX] 12 unread | synced 2m ago"
2555 );
2556 assert_eq!(
2557 status_bar::format_sync_status(0, None),
2558 "[INBOX] 0 unread | not synced"
2559 );
2560 }
2561
2562 fn make_test_labels() -> Vec<Label> {
2563 vec![
2564 Label {
2565 id: LabelId::from_provider_id("test", "INBOX"),
2566 account_id: AccountId::new(),
2567 name: "INBOX".to_string(),
2568 kind: LabelKind::System,
2569 color: None,
2570 provider_id: "INBOX".to_string(),
2571 unread_count: 3,
2572 total_count: 10,
2573 },
2574 Label {
2575 id: LabelId::from_provider_id("test", "STARRED"),
2576 account_id: AccountId::new(),
2577 name: "STARRED".to_string(),
2578 kind: LabelKind::System,
2579 color: None,
2580 provider_id: "STARRED".to_string(),
2581 unread_count: 0,
2582 total_count: 2,
2583 },
2584 Label {
2585 id: LabelId::from_provider_id("test", "SENT"),
2586 account_id: AccountId::new(),
2587 name: "SENT".to_string(),
2588 kind: LabelKind::System,
2589 color: None,
2590 provider_id: "SENT".to_string(),
2591 unread_count: 0,
2592 total_count: 5,
2593 },
2594 Label {
2595 id: LabelId::from_provider_id("test", "DRAFT"),
2596 account_id: AccountId::new(),
2597 name: "DRAFT".to_string(),
2598 kind: LabelKind::System,
2599 color: None,
2600 provider_id: "DRAFT".to_string(),
2601 unread_count: 0,
2602 total_count: 0,
2603 },
2604 Label {
2605 id: LabelId::from_provider_id("test", "ARCHIVE"),
2606 account_id: AccountId::new(),
2607 name: "ARCHIVE".to_string(),
2608 kind: LabelKind::System,
2609 color: None,
2610 provider_id: "ARCHIVE".to_string(),
2611 unread_count: 0,
2612 total_count: 0,
2613 },
2614 Label {
2615 id: LabelId::from_provider_id("test", "SPAM"),
2616 account_id: AccountId::new(),
2617 name: "SPAM".to_string(),
2618 kind: LabelKind::System,
2619 color: None,
2620 provider_id: "SPAM".to_string(),
2621 unread_count: 0,
2622 total_count: 0,
2623 },
2624 Label {
2625 id: LabelId::from_provider_id("test", "TRASH"),
2626 account_id: AccountId::new(),
2627 name: "TRASH".to_string(),
2628 kind: LabelKind::System,
2629 color: None,
2630 provider_id: "TRASH".to_string(),
2631 unread_count: 0,
2632 total_count: 0,
2633 },
2634 Label {
2636 id: LabelId::from_provider_id("test", "CHAT"),
2637 account_id: AccountId::new(),
2638 name: "CHAT".to_string(),
2639 kind: LabelKind::System,
2640 color: None,
2641 provider_id: "CHAT".to_string(),
2642 unread_count: 0,
2643 total_count: 0,
2644 },
2645 Label {
2646 id: LabelId::from_provider_id("test", "IMPORTANT"),
2647 account_id: AccountId::new(),
2648 name: "IMPORTANT".to_string(),
2649 kind: LabelKind::System,
2650 color: None,
2651 provider_id: "IMPORTANT".to_string(),
2652 unread_count: 0,
2653 total_count: 5,
2654 },
2655 Label {
2657 id: LabelId::from_provider_id("test", "Work"),
2658 account_id: AccountId::new(),
2659 name: "Work".to_string(),
2660 kind: LabelKind::User,
2661 color: None,
2662 provider_id: "Label_1".to_string(),
2663 unread_count: 2,
2664 total_count: 10,
2665 },
2666 Label {
2667 id: LabelId::from_provider_id("test", "Personal"),
2668 account_id: AccountId::new(),
2669 name: "Personal".to_string(),
2670 kind: LabelKind::User,
2671 color: None,
2672 provider_id: "Label_2".to_string(),
2673 unread_count: 0,
2674 total_count: 3,
2675 },
2676 Label {
2678 id: LabelId::from_provider_id("test", "CATEGORY_UPDATES"),
2679 account_id: AccountId::new(),
2680 name: "CATEGORY_UPDATES".to_string(),
2681 kind: LabelKind::System,
2682 color: None,
2683 provider_id: "CATEGORY_UPDATES".to_string(),
2684 unread_count: 0,
2685 total_count: 50,
2686 },
2687 ]
2688 }
2689
2690 #[test]
2693 fn threepane_l_loads_new_message() {
2694 let mut app = App::new();
2695 app.envelopes = make_test_envelopes(5);
2696 app.all_envelopes = app.envelopes.clone();
2697 app.apply(Action::OpenSelected);
2699 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2700 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2701 app.active_pane = ActivePane::MailList;
2703 app.apply(Action::MoveDown);
2705 app.apply(Action::OpenSelected);
2707 let second_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2708 assert_ne!(
2709 first_id, second_id,
2710 "l should load the new message, not stay on old one"
2711 );
2712 assert_eq!(app.selected_index, 1);
2713 }
2714
2715 #[test]
2716 fn threepane_jk_auto_preview() {
2717 let mut app = App::new();
2718 app.envelopes = make_test_envelopes(5);
2719 app.all_envelopes = app.envelopes.clone();
2720 app.apply(Action::OpenSelected);
2722 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2723 let first_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2724 app.active_pane = ActivePane::MailList;
2726 app.apply(Action::MoveDown);
2728 let preview_id = app.viewing_envelope.as_ref().unwrap().id.clone();
2729 assert_ne!(first_id, preview_id, "j/k should auto-preview in ThreePane");
2730 }
2733
2734 #[test]
2735 fn twopane_jk_no_auto_preview() {
2736 let mut app = App::new();
2737 app.envelopes = make_test_envelopes(5);
2738 app.all_envelopes = app.envelopes.clone();
2739 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2741 app.apply(Action::MoveDown);
2742 assert!(
2743 app.viewing_envelope.is_none(),
2744 "j/k should not auto-preview in TwoPane"
2745 );
2746 }
2748
2749 #[test]
2752 fn back_in_message_view_closes_preview_pane() {
2753 let mut app = App::new();
2754 app.envelopes = make_test_envelopes(3);
2755 app.all_envelopes = app.envelopes.clone();
2756 app.apply(Action::OpenSelected);
2757 assert_eq!(app.active_pane, ActivePane::MessageView);
2758 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
2759 app.apply(Action::Back);
2760 assert_eq!(app.active_pane, ActivePane::MailList);
2761 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2762 assert!(app.viewing_envelope.is_none());
2763 }
2764
2765 #[test]
2766 fn back_in_mail_list_clears_label_filter() {
2767 let mut app = App::new();
2768 app.envelopes = make_test_envelopes(5);
2769 app.all_envelopes = app.envelopes.clone();
2770 app.labels = make_test_labels();
2771 let inbox_id = app
2772 .labels
2773 .iter()
2774 .find(|l| l.name == "INBOX")
2775 .unwrap()
2776 .id
2777 .clone();
2778 app.active_label = Some(inbox_id);
2780 app.envelopes = vec![app.envelopes[0].clone()]; app.apply(Action::Back);
2783 assert!(app.active_label.is_none(), "Esc should clear label filter");
2784 assert_eq!(app.envelopes.len(), 5, "Should restore all envelopes");
2785 }
2786
2787 #[test]
2788 fn back_in_mail_list_closes_threepane_when_no_filter() {
2789 let mut app = App::new();
2790 app.envelopes = make_test_envelopes(3);
2791 app.all_envelopes = app.envelopes.clone();
2792 app.apply(Action::OpenSelected); app.active_pane = ActivePane::MailList; app.apply(Action::Back);
2796 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
2797 }
2798
2799 #[test]
2802 fn sidebar_system_labels_before_user_labels() {
2803 let mut app = App::new();
2804 app.labels = make_test_labels();
2805 let ordered = app.ordered_visible_labels();
2806 let first_user_idx = ordered.iter().position(|l| l.kind == LabelKind::User);
2808 let last_system_idx = ordered.iter().rposition(|l| l.kind == LabelKind::System);
2809 if let (Some(first_user), Some(last_system)) = (first_user_idx, last_system_idx) {
2810 assert!(
2811 last_system < first_user,
2812 "All system labels should come before user labels"
2813 );
2814 }
2815 }
2816
2817 #[test]
2818 fn sidebar_system_labels_in_correct_order() {
2819 let mut app = App::new();
2820 app.labels = make_test_labels();
2821 let ordered = app.ordered_visible_labels();
2822 let system_names: Vec<&str> = ordered
2823 .iter()
2824 .filter(|l| l.kind == LabelKind::System)
2825 .map(|l| l.name.as_str())
2826 .collect();
2827 assert_eq!(system_names[0], "INBOX");
2829 assert_eq!(system_names[1], "STARRED");
2830 assert_eq!(system_names[2], "SENT");
2831 assert_eq!(system_names[3], "DRAFT");
2832 assert_eq!(system_names[4], "ARCHIVE");
2833 }
2834
2835 #[test]
2836 fn sidebar_items_put_inbox_before_all_mail() {
2837 let mut app = App::new();
2838 app.labels = make_test_labels();
2839
2840 let items = app.sidebar_items();
2841 let all_mail_index = items
2842 .iter()
2843 .position(|item| matches!(item, SidebarItem::AllMail))
2844 .unwrap();
2845
2846 assert!(matches!(
2847 items.first(),
2848 Some(SidebarItem::Label(label)) if label.name == "INBOX"
2849 ));
2850 assert!(all_mail_index > 0);
2851 }
2852
2853 #[test]
2854 fn sidebar_hidden_labels_not_shown() {
2855 let mut app = App::new();
2856 app.labels = make_test_labels();
2857 let ordered = app.ordered_visible_labels();
2858 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2859 assert!(
2860 !names.contains(&"CATEGORY_UPDATES"),
2861 "Gmail categories should be hidden"
2862 );
2863 }
2864
2865 #[test]
2866 fn sidebar_empty_system_labels_hidden_except_primary() {
2867 let mut app = App::new();
2868 app.labels = make_test_labels();
2869 let ordered = app.ordered_visible_labels();
2870 let names: Vec<&str> = ordered.iter().map(|l| l.name.as_str()).collect();
2871 assert!(
2873 !names.contains(&"CHAT"),
2874 "Empty non-primary system labels should be hidden"
2875 );
2876 assert!(
2878 names.contains(&"DRAFT"),
2879 "Primary system labels shown even if empty"
2880 );
2881 assert!(
2882 names.contains(&"ARCHIVE"),
2883 "Archive should be shown as a primary system label even if empty"
2884 );
2885 assert!(
2887 names.contains(&"IMPORTANT"),
2888 "Non-empty system labels should be shown"
2889 );
2890 }
2891
2892 #[test]
2893 fn sidebar_user_labels_alphabetical() {
2894 let mut app = App::new();
2895 app.labels = make_test_labels();
2896 let ordered = app.ordered_visible_labels();
2897 let user_names: Vec<&str> = ordered
2898 .iter()
2899 .filter(|l| l.kind == LabelKind::User)
2900 .map(|l| l.name.as_str())
2901 .collect();
2902 assert_eq!(user_names, vec!["Personal", "Work"]);
2904 }
2905
2906 #[test]
2909 fn goto_inbox_sets_active_label() {
2910 let mut app = App::new();
2911 app.envelopes = make_test_envelopes(5);
2912 app.all_envelopes = app.envelopes.clone();
2913 app.labels = make_test_labels();
2914 app.apply(Action::GoToInbox);
2915 let label = app.labels.iter().find(|l| l.name == "INBOX").unwrap();
2916 assert!(
2917 app.active_label.is_none(),
2918 "GoToInbox should wait for fetch success before swapping active label"
2919 );
2920 assert_eq!(app.pending_active_label.as_ref().unwrap(), &label.id);
2921 assert!(
2922 app.pending_label_fetch.is_some(),
2923 "Should trigger label fetch"
2924 );
2925 }
2926
2927 #[test]
2928 fn goto_inbox_without_labels_records_desired_mailbox() {
2929 let mut app = App::new();
2930 app.apply(Action::GoToInbox);
2931 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
2932 assert!(app.pending_label_fetch.is_none());
2933 assert!(app.pending_active_label.is_none());
2934 }
2935
2936 #[test]
2937 fn labels_refresh_resolves_desired_inbox() {
2938 let mut app = App::new();
2939 app.desired_system_mailbox = Some("INBOX".into());
2940 app.labels = make_test_labels();
2941
2942 app.resolve_desired_system_mailbox();
2943
2944 let inbox_id = app
2945 .labels
2946 .iter()
2947 .find(|label| label.name == "INBOX")
2948 .unwrap()
2949 .id
2950 .clone();
2951 assert_eq!(app.pending_active_label.as_ref(), Some(&inbox_id));
2952 assert_eq!(app.pending_label_fetch.as_ref(), Some(&inbox_id));
2953 assert!(app.active_label.is_none());
2954 }
2955
2956 #[test]
2957 fn sync_completed_requests_live_refresh_even_without_active_label() {
2958 let mut app = App::new();
2959
2960 handle_daemon_event(
2961 &mut app,
2962 DaemonEvent::SyncCompleted {
2963 account_id: AccountId::new(),
2964 messages_synced: 5,
2965 },
2966 );
2967
2968 assert!(app.pending_labels_refresh);
2969 assert!(app.pending_all_envelopes_refresh);
2970 assert!(app.pending_status_refresh);
2971 assert!(app.pending_label_fetch.is_none());
2972 assert_eq!(app.status_message.as_deref(), Some("Synced 5 messages"));
2973 }
2974
2975 #[test]
2976 fn status_bar_uses_label_counts_instead_of_loaded_window() {
2977 let mut app = App::new();
2978 let mut envelopes = make_test_envelopes(5);
2979 if let Some(first) = envelopes.first_mut() {
2980 first.flags.remove(MessageFlags::READ);
2981 first.flags.insert(MessageFlags::STARRED);
2982 }
2983 app.envelopes = envelopes.clone();
2984 app.all_envelopes = envelopes;
2985 app.labels = make_test_labels();
2986 let inbox = app
2987 .labels
2988 .iter()
2989 .find(|label| label.name == "INBOX")
2990 .unwrap()
2991 .id
2992 .clone();
2993 app.active_label = Some(inbox);
2994 app.last_sync_status = Some("synced just now".into());
2995
2996 let state = app.status_bar_state();
2997
2998 assert_eq!(state.mailbox_name, "INBOX");
2999 assert_eq!(state.total_count, 10);
3000 assert_eq!(state.unread_count, 3);
3001 assert_eq!(state.starred_count, 2);
3002 assert_eq!(state.sync_status.as_deref(), Some("synced just now"));
3003 }
3004
3005 #[test]
3006 fn all_envelopes_refresh_updates_visible_all_mail() {
3007 let mut app = App::new();
3008 let envelopes = make_test_envelopes(4);
3009 app.active_label = None;
3010 app.search_active = false;
3011
3012 apply_all_envelopes_refresh(&mut app, envelopes.clone());
3013
3014 assert_eq!(app.all_envelopes.len(), 4);
3015 assert_eq!(app.envelopes.len(), 4);
3016 assert_eq!(app.selected_index, 0);
3017 }
3018
3019 #[test]
3020 fn all_envelopes_refresh_preserves_selection_when_possible() {
3021 let mut app = App::new();
3022 app.visible_height = 3;
3023 app.mail_list_mode = MailListMode::Messages;
3024 let initial = make_test_envelopes(4);
3025 app.all_envelopes = initial.clone();
3026 app.envelopes = initial.clone();
3027 app.selected_index = 2;
3028 app.scroll_offset = 1;
3029
3030 let mut refreshed = initial.clone();
3031 refreshed.push(make_test_envelopes(1).remove(0));
3032
3033 apply_all_envelopes_refresh(&mut app, refreshed);
3034
3035 assert_eq!(app.selected_index, 2);
3036 assert_eq!(app.envelopes[app.selected_index].id, initial[2].id);
3037 assert_eq!(app.scroll_offset, 1);
3038 }
3039
3040 #[test]
3041 fn all_envelopes_refresh_preserves_selected_message_when_rows_shift() {
3042 let mut app = App::new();
3043 app.mail_list_mode = MailListMode::Messages;
3044 let initial = make_test_envelopes(4);
3045 let selected_id = initial[2].id.clone();
3046 app.all_envelopes = initial.clone();
3047 app.envelopes = initial;
3048 app.selected_index = 2;
3049
3050 let mut refreshed = make_test_envelopes(1);
3051 refreshed.extend(app.envelopes.clone());
3052
3053 apply_all_envelopes_refresh(&mut app, refreshed);
3054
3055 assert_eq!(app.envelopes[app.selected_index].id, selected_id);
3056 }
3057
3058 #[test]
3059 fn all_envelopes_refresh_preserves_pending_label_view() {
3060 let mut app = App::new();
3061 let labels = make_test_labels();
3062 let inbox_id = labels
3063 .iter()
3064 .find(|label| label.name == "INBOX")
3065 .unwrap()
3066 .id
3067 .clone();
3068 let initial = make_test_envelopes(2);
3069 let refreshed = make_test_envelopes(5);
3070 app.labels = labels;
3071 app.envelopes = initial.clone();
3072 app.all_envelopes = initial;
3073 app.pending_active_label = Some(inbox_id);
3074
3075 apply_all_envelopes_refresh(&mut app, refreshed.clone());
3076
3077 assert_eq!(app.all_envelopes.len(), refreshed.len());
3078 assert_eq!(app.all_envelopes[0].id, refreshed[0].id);
3079 assert_eq!(app.envelopes.len(), 2);
3080 }
3081
3082 #[test]
3083 fn label_counts_refresh_can_follow_empty_boot() {
3084 let mut app = App::new();
3085 app.desired_system_mailbox = Some("INBOX".into());
3086
3087 handle_daemon_event(
3088 &mut app,
3089 DaemonEvent::SyncCompleted {
3090 account_id: AccountId::new(),
3091 messages_synced: 0,
3092 },
3093 );
3094
3095 assert!(app.pending_labels_refresh);
3096 assert!(app.pending_all_envelopes_refresh);
3097 assert_eq!(app.desired_system_mailbox.as_deref(), Some("INBOX"));
3098 }
3099
3100 #[test]
3101 fn clear_filter_restores_all_envelopes() {
3102 let mut app = App::new();
3103 app.envelopes = make_test_envelopes(10);
3104 app.all_envelopes = app.envelopes.clone();
3105 app.labels = make_test_labels();
3106 let inbox_id = app
3107 .labels
3108 .iter()
3109 .find(|l| l.name == "INBOX")
3110 .unwrap()
3111 .id
3112 .clone();
3113 app.active_label = Some(inbox_id);
3114 app.envelopes = vec![app.envelopes[0].clone()]; app.selected_index = 0;
3116 app.apply(Action::ClearFilter);
3117 assert!(app.active_label.is_none());
3118 assert_eq!(app.envelopes.len(), 10, "Should restore full list");
3119 }
3120
3121 #[test]
3124 fn archive_removes_from_list() {
3125 let mut app = App::new();
3126 app.envelopes = make_test_envelopes(5);
3127 app.all_envelopes = app.envelopes.clone();
3128 let removed_id = app.envelopes[0].id.clone();
3129 app.apply(Action::Archive);
3130 assert!(!app.pending_mutation_queue.is_empty());
3131 assert_eq!(app.envelopes.len(), 4);
3132 assert!(!app
3133 .envelopes
3134 .iter()
3135 .any(|envelope| envelope.id == removed_id));
3136 }
3137
3138 #[test]
3139 fn star_updates_flags_in_place() {
3140 let mut app = App::new();
3141 app.envelopes = make_test_envelopes(3);
3142 app.all_envelopes = app.envelopes.clone();
3143 assert!(!app.envelopes[0].flags.contains(MessageFlags::STARRED));
3145 app.apply(Action::Star);
3146 assert!(!app.pending_mutation_queue.is_empty());
3147 assert_eq!(app.pending_mutation_count, 1);
3148 assert!(app.envelopes[0].flags.contains(MessageFlags::STARRED));
3149 }
3150
3151 #[test]
3152 fn bulk_mark_read_applies_flags_when_confirmed() {
3153 let mut app = App::new();
3154 let mut envelopes = make_test_envelopes(3);
3155 for envelope in &mut envelopes {
3156 envelope.flags.remove(MessageFlags::READ);
3157 }
3158 app.envelopes = envelopes.clone();
3159 app.all_envelopes = envelopes.clone();
3160 app.selected_set = envelopes
3161 .iter()
3162 .map(|envelope| envelope.id.clone())
3163 .collect();
3164
3165 app.apply(Action::MarkRead);
3166 assert!(app.pending_mutation_queue.is_empty());
3167 assert!(app.pending_bulk_confirm.is_some());
3168 assert!(app
3169 .envelopes
3170 .iter()
3171 .all(|envelope| !envelope.flags.contains(MessageFlags::READ)));
3172
3173 app.apply(Action::OpenSelected);
3174
3175 assert_eq!(app.pending_mutation_queue.len(), 1);
3176 assert_eq!(app.pending_mutation_count, 1);
3177 assert!(app.pending_bulk_confirm.is_none());
3178 assert!(app
3179 .envelopes
3180 .iter()
3181 .all(|envelope| envelope.flags.contains(MessageFlags::READ)));
3182 assert_eq!(
3183 app.pending_mutation_status.as_deref(),
3184 Some("Marking 3 messages as read...")
3185 );
3186 }
3187
3188 #[test]
3189 fn status_bar_shows_pending_mutation_indicator_after_other_actions() {
3190 let mut app = App::new();
3191 let mut envelopes = make_test_envelopes(2);
3192 for envelope in &mut envelopes {
3193 envelope.flags.remove(MessageFlags::READ);
3194 }
3195 app.envelopes = envelopes.clone();
3196 app.all_envelopes = envelopes;
3197
3198 app.apply(Action::MarkRead);
3199 app.apply(Action::MoveDown);
3200
3201 let state = app.status_bar_state();
3202 assert_eq!(state.pending_mutation_count, 1);
3203 assert_eq!(
3204 state.pending_mutation_status.as_deref(),
3205 Some("Marking 1 message as read...")
3206 );
3207 }
3208
3209 #[test]
3210 fn mark_read_and_archive_removes_message_optimistically_and_queues_mutation() {
3211 let mut app = App::new();
3212 let mut envelopes = make_test_envelopes(1);
3213 envelopes[0].flags.remove(MessageFlags::READ);
3214 app.envelopes = envelopes.clone();
3215 app.all_envelopes = envelopes;
3216 let message_id = app.envelopes[0].id.clone();
3217
3218 app.apply(Action::MarkReadAndArchive);
3219
3220 assert!(app.envelopes.is_empty());
3221 assert!(app.all_envelopes.is_empty());
3222 assert_eq!(app.pending_mutation_queue.len(), 1);
3223 match &app.pending_mutation_queue[0].0 {
3224 Request::Mutation(MutationCommand::ReadAndArchive { message_ids }) => {
3225 assert_eq!(message_ids, &vec![message_id]);
3226 }
3227 other => panic!("expected read-and-archive mutation, got {other:?}"),
3228 }
3229 }
3230
3231 #[test]
3232 fn bulk_mark_read_and_archive_removes_messages_when_confirmed() {
3233 let mut app = App::new();
3234 let mut envelopes = make_test_envelopes(3);
3235 for envelope in &mut envelopes {
3236 envelope.flags.remove(MessageFlags::READ);
3237 }
3238 app.envelopes = envelopes.clone();
3239 app.all_envelopes = envelopes.clone();
3240 app.selected_set = envelopes
3241 .iter()
3242 .map(|envelope| envelope.id.clone())
3243 .collect();
3244
3245 app.apply(Action::MarkReadAndArchive);
3246 assert!(app.pending_bulk_confirm.is_some());
3247 assert_eq!(app.envelopes.len(), 3);
3248
3249 app.apply(Action::OpenSelected);
3250
3251 assert!(app.pending_bulk_confirm.is_none());
3252 assert_eq!(app.pending_mutation_queue.len(), 1);
3253 assert_eq!(app.pending_mutation_count, 1);
3254 assert!(app.envelopes.is_empty());
3255 assert!(app.all_envelopes.is_empty());
3256 assert_eq!(
3257 app.pending_mutation_status.as_deref(),
3258 Some("Marking 3 messages as read and archiving...")
3259 );
3260 }
3261
3262 #[test]
3263 fn mutation_failure_opens_error_modal_and_refreshes_mailbox() {
3264 let mut app = App::new();
3265
3266 app.show_mutation_failure(&MxrError::Ipc("boom".into()));
3267 app.refresh_mailbox_after_mutation_failure();
3268
3269 assert!(app.error_modal.is_some());
3270 assert_eq!(
3271 app.error_modal.as_ref().map(|modal| modal.title.as_str()),
3272 Some("Mutation Failed")
3273 );
3274 assert!(app.pending_labels_refresh);
3275 assert!(app.pending_all_envelopes_refresh);
3276 assert!(app.pending_status_refresh);
3277 assert!(app.pending_subscriptions_refresh);
3278 }
3279
3280 #[test]
3281 fn mutation_failure_reloads_pending_label_fetch() {
3282 let mut app = App::new();
3283 let inbox_id = LabelId::new();
3284 app.pending_active_label = Some(inbox_id.clone());
3285
3286 app.refresh_mailbox_after_mutation_failure();
3287
3288 assert_eq!(app.pending_label_fetch.as_ref(), Some(&inbox_id));
3289 }
3290
3291 #[test]
3292 fn archive_viewing_message_effect() {
3293 let mut app = App::new();
3294 app.envelopes = make_test_envelopes(3);
3295 app.all_envelopes = app.envelopes.clone();
3296 app.apply(Action::OpenSelected);
3298 assert!(app.viewing_envelope.is_some());
3299 let viewing_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3300 app.apply(Action::Archive);
3303 let (_, effect) = app.pending_mutation_queue.remove(0);
3304 match &effect {
3306 MutationEffect::RemoveFromList(id) => {
3307 assert_eq!(*id, viewing_id);
3308 }
3309 _ => panic!("Expected RemoveFromList"),
3310 }
3311 }
3312
3313 #[test]
3314 fn archive_keeps_reader_open_and_selects_next_message() {
3315 let mut app = App::new();
3316 app.envelopes = make_test_envelopes(3);
3317 app.all_envelopes = app.envelopes.clone();
3318
3319 app.apply(Action::OpenSelected);
3320 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3321 let next_id = app.envelopes[1].id.clone();
3322
3323 app.apply_removed_message_ids(&[removed_id]);
3324
3325 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3326 assert_eq!(app.selected_index, 0);
3327 assert_eq!(app.active_pane, ActivePane::MessageView);
3328 assert_eq!(
3329 app.viewing_envelope
3330 .as_ref()
3331 .map(|envelope| envelope.id.clone()),
3332 Some(next_id)
3333 );
3334 }
3335
3336 #[test]
3337 fn archive_keeps_mail_list_focus_when_reader_was_visible() {
3338 let mut app = App::new();
3339 app.envelopes = make_test_envelopes(3);
3340 app.all_envelopes = app.envelopes.clone();
3341
3342 app.apply(Action::OpenSelected);
3343 app.active_pane = ActivePane::MailList;
3344 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3345 let next_id = app.envelopes[1].id.clone();
3346
3347 app.apply_removed_message_ids(&[removed_id]);
3348
3349 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3350 assert_eq!(app.active_pane, ActivePane::MailList);
3351 assert_eq!(
3352 app.viewing_envelope
3353 .as_ref()
3354 .map(|envelope| envelope.id.clone()),
3355 Some(next_id)
3356 );
3357 }
3358
3359 #[test]
3360 fn archive_last_visible_message_closes_reader() {
3361 let mut app = App::new();
3362 app.envelopes = make_test_envelopes(1);
3363 app.all_envelopes = app.envelopes.clone();
3364
3365 app.apply(Action::OpenSelected);
3366 let removed_id = app.viewing_envelope.as_ref().unwrap().id.clone();
3367
3368 app.apply_removed_message_ids(&[removed_id]);
3369
3370 assert_eq!(app.layout_mode, LayoutMode::TwoPane);
3371 assert_eq!(app.active_pane, ActivePane::MailList);
3372 assert!(app.viewing_envelope.is_none());
3373 assert!(app.envelopes.is_empty());
3374 }
3375
3376 #[test]
3379 fn mail_list_title_shows_message_count() {
3380 let mut app = App::new();
3381 app.envelopes = make_test_envelopes(5);
3382 app.all_envelopes = app.envelopes.clone();
3383 let title = app.mail_list_title();
3384 assert!(title.contains("5"), "Title should show message count");
3385 assert!(
3386 title.contains("Threads"),
3387 "Default title should say Threads"
3388 );
3389 }
3390
3391 #[test]
3392 fn mail_list_title_shows_label_name() {
3393 let mut app = App::new();
3394 app.envelopes = make_test_envelopes(5);
3395 app.all_envelopes = app.envelopes.clone();
3396 app.labels = make_test_labels();
3397 let inbox_id = app
3398 .labels
3399 .iter()
3400 .find(|l| l.name == "INBOX")
3401 .unwrap()
3402 .id
3403 .clone();
3404 app.active_label = Some(inbox_id);
3405 let title = app.mail_list_title();
3406 assert!(
3407 title.contains("Inbox"),
3408 "Title should show humanized label name"
3409 );
3410 }
3411
3412 #[test]
3413 fn mail_list_title_shows_search_query() {
3414 let mut app = App::new();
3415 app.envelopes = make_test_envelopes(5);
3416 app.all_envelopes = app.envelopes.clone();
3417 app.search_active = true;
3418 app.search_bar.query = "deployment".to_string();
3419 let title = app.mail_list_title();
3420 assert!(
3421 title.contains("deployment"),
3422 "Title should show search query"
3423 );
3424 assert!(title.contains("Search"), "Title should indicate search");
3425 }
3426
3427 #[test]
3428 fn message_view_body_display() {
3429 let mut app = App::new();
3430 app.envelopes = make_test_envelopes(3);
3431 app.all_envelopes = app.envelopes.clone();
3432 app.apply(Action::OpenMessageView);
3433 assert_eq!(app.layout_mode, LayoutMode::ThreePane);
3434 app.body_view_state = BodyViewState::Ready {
3435 raw: "Hello".into(),
3436 rendered: "Hello".into(),
3437 source: BodySource::Plain,
3438 };
3439 assert_eq!(app.body_view_state.display_text(), Some("Hello"));
3440 app.apply(Action::CloseMessageView);
3441 assert!(matches!(app.body_view_state, BodyViewState::Empty { .. }));
3442 }
3443
3444 #[test]
3445 fn close_message_view_preserves_reader_mode() {
3446 let mut app = App::new();
3447 app.envelopes = make_test_envelopes(1);
3448 app.all_envelopes = app.envelopes.clone();
3449 app.apply(Action::OpenMessageView);
3450
3451 app.apply(Action::CloseMessageView);
3452
3453 assert!(app.reader_mode);
3454 }
3455
3456 #[test]
3457 fn open_selected_populates_visible_thread_messages() {
3458 let mut app = App::new();
3459 app.envelopes = make_test_envelopes(3);
3460 let shared_thread = ThreadId::new();
3461 app.envelopes[0].thread_id = shared_thread.clone();
3462 app.envelopes[1].thread_id = shared_thread;
3463 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3464 app.envelopes[1].date = chrono::Utc::now();
3465 app.all_envelopes = app.envelopes.clone();
3466
3467 app.apply(Action::OpenSelected);
3468
3469 assert_eq!(app.viewed_thread_messages.len(), 2);
3470 assert_eq!(app.viewed_thread_messages[0].id, app.envelopes[0].id);
3471 assert_eq!(app.viewed_thread_messages[1].id, app.envelopes[1].id);
3472 }
3473
3474 #[test]
3475 fn mail_list_defaults_to_threads() {
3476 let mut app = App::new();
3477 app.envelopes = make_test_envelopes(3);
3478 let shared_thread = ThreadId::new();
3479 app.envelopes[0].thread_id = shared_thread.clone();
3480 app.envelopes[1].thread_id = shared_thread;
3481 app.all_envelopes = app.envelopes.clone();
3482
3483 assert_eq!(app.mail_list_rows().len(), 2);
3484 assert_eq!(
3485 app.selected_mail_row().map(|row| row.message_count),
3486 Some(2)
3487 );
3488 }
3489
3490 #[test]
3491 fn open_thread_focuses_latest_unread_message() {
3492 let mut app = App::new();
3493 app.envelopes = make_test_envelopes(3);
3494 let shared_thread = ThreadId::new();
3495 app.envelopes[0].thread_id = shared_thread.clone();
3496 app.envelopes[1].thread_id = shared_thread;
3497 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(10);
3498 app.envelopes[1].date = chrono::Utc::now();
3499 app.envelopes[0].flags = MessageFlags::READ;
3500 app.envelopes[1].flags = MessageFlags::empty();
3501 app.all_envelopes = app.envelopes.clone();
3502
3503 app.apply(Action::OpenSelected);
3504
3505 assert_eq!(app.thread_selected_index, 1);
3506 assert_eq!(
3507 app.focused_thread_envelope().map(|env| env.id.clone()),
3508 Some(app.envelopes[1].id.clone())
3509 );
3510 }
3511
3512 #[test]
3513 fn open_selected_marks_unread_message_read_after_dwell() {
3514 let mut app = App::new();
3515 app.envelopes = make_test_envelopes(1);
3516 app.envelopes[0].flags = MessageFlags::empty();
3517 app.all_envelopes = app.envelopes.clone();
3518
3519 app.apply(Action::OpenSelected);
3520
3521 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3522 assert!(!app.all_envelopes[0].flags.contains(MessageFlags::READ));
3523 assert!(!app.viewed_thread_messages[0]
3524 .flags
3525 .contains(MessageFlags::READ));
3526 assert!(!app
3527 .viewing_envelope
3528 .as_ref()
3529 .unwrap()
3530 .flags
3531 .contains(MessageFlags::READ));
3532 assert!(app.pending_mutation_queue.is_empty());
3533
3534 app.expire_pending_preview_read_for_tests();
3535 app.tick();
3536
3537 assert!(app.envelopes[0].flags.contains(MessageFlags::READ));
3538 assert!(app.all_envelopes[0].flags.contains(MessageFlags::READ));
3539 assert!(app.viewed_thread_messages[0]
3540 .flags
3541 .contains(MessageFlags::READ));
3542 assert!(app
3543 .viewing_envelope
3544 .as_ref()
3545 .unwrap()
3546 .flags
3547 .contains(MessageFlags::READ));
3548 assert_eq!(app.pending_mutation_queue.len(), 1);
3549 match &app.pending_mutation_queue[0].0 {
3550 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3551 assert!(*read);
3552 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3553 }
3554 other => panic!("expected set-read mutation, got {other:?}"),
3555 }
3556 }
3557
3558 #[test]
3559 fn open_selected_on_read_message_does_not_queue_read_mutation() {
3560 let mut app = App::new();
3561 app.envelopes = make_test_envelopes(1);
3562 app.envelopes[0].flags = MessageFlags::READ;
3563 app.all_envelopes = app.envelopes.clone();
3564
3565 app.apply(Action::OpenSelected);
3566 app.expire_pending_preview_read_for_tests();
3567 app.tick();
3568
3569 assert!(app.pending_mutation_queue.is_empty());
3570 }
3571
3572 #[test]
3573 fn reopening_same_message_does_not_queue_duplicate_read_mutation() {
3574 let mut app = App::new();
3575 app.envelopes = make_test_envelopes(1);
3576 app.envelopes[0].flags = MessageFlags::empty();
3577 app.all_envelopes = app.envelopes.clone();
3578
3579 app.apply(Action::OpenSelected);
3580 app.apply(Action::OpenSelected);
3581
3582 assert!(app.pending_mutation_queue.is_empty());
3583 app.expire_pending_preview_read_for_tests();
3584 app.tick();
3585 assert_eq!(app.pending_mutation_queue.len(), 1);
3586 }
3587
3588 #[test]
3589 fn thread_move_down_changes_reply_target() {
3590 let mut app = App::new();
3591 app.envelopes = make_test_envelopes(2);
3592 let shared_thread = ThreadId::new();
3593 app.envelopes[0].thread_id = shared_thread.clone();
3594 app.envelopes[1].thread_id = shared_thread;
3595 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3596 app.envelopes[1].date = chrono::Utc::now();
3597 app.envelopes[0].flags = MessageFlags::empty();
3598 app.envelopes[1].flags = MessageFlags::READ;
3599 app.all_envelopes = app.envelopes.clone();
3600
3601 app.apply(Action::OpenSelected);
3602 assert_eq!(
3603 app.focused_thread_envelope().map(|env| env.id.clone()),
3604 Some(app.envelopes[0].id.clone())
3605 );
3606
3607 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
3608
3609 assert_eq!(
3610 app.focused_thread_envelope().map(|env| env.id.clone()),
3611 Some(app.envelopes[1].id.clone())
3612 );
3613 app.apply(Action::Reply);
3614 assert_eq!(
3615 app.pending_compose,
3616 Some(super::app::ComposeAction::Reply {
3617 message_id: app.envelopes[1].id.clone()
3618 })
3619 );
3620 }
3621
3622 #[test]
3623 fn thread_focus_change_marks_newly_focused_unread_message_read_after_dwell() {
3624 let mut app = App::new();
3625 app.envelopes = make_test_envelopes(2);
3626 let shared_thread = ThreadId::new();
3627 app.envelopes[0].thread_id = shared_thread.clone();
3628 app.envelopes[1].thread_id = shared_thread;
3629 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(5);
3630 app.envelopes[1].date = chrono::Utc::now();
3631 app.envelopes[0].flags = MessageFlags::empty();
3632 app.envelopes[1].flags = MessageFlags::empty();
3633 app.all_envelopes = app.envelopes.clone();
3634
3635 app.apply(Action::OpenSelected);
3636 assert_eq!(app.thread_selected_index, 1);
3637 assert!(app.pending_mutation_queue.is_empty());
3638
3639 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
3640
3641 assert_eq!(app.thread_selected_index, 0);
3642 assert!(!app.viewed_thread_messages[0]
3643 .flags
3644 .contains(MessageFlags::READ));
3645 assert!(app.pending_mutation_queue.is_empty());
3646
3647 app.expire_pending_preview_read_for_tests();
3648 app.tick();
3649
3650 assert!(app.viewed_thread_messages[0]
3651 .flags
3652 .contains(MessageFlags::READ));
3653 assert!(app
3654 .viewing_envelope
3655 .as_ref()
3656 .unwrap()
3657 .flags
3658 .contains(MessageFlags::READ));
3659 assert_eq!(app.pending_mutation_queue.len(), 1);
3660 match &app.pending_mutation_queue[0].0 {
3661 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3662 assert!(*read);
3663 assert_eq!(message_ids, &vec![app.envelopes[0].id.clone()]);
3664 }
3665 other => panic!("expected set-read mutation, got {other:?}"),
3666 }
3667 }
3668
3669 #[test]
3670 fn preview_navigation_only_marks_message_read_after_settling() {
3671 let mut app = App::new();
3672 app.envelopes = make_test_envelopes(2);
3673 app.envelopes[0].flags = MessageFlags::empty();
3674 app.envelopes[1].flags = MessageFlags::empty();
3675 app.envelopes[0].thread_id = ThreadId::new();
3676 app.envelopes[1].thread_id = ThreadId::new();
3677 app.envelopes[0].date = chrono::Utc::now() - chrono::Duration::minutes(1);
3678 app.envelopes[1].date = chrono::Utc::now();
3679 app.all_envelopes = app.envelopes.clone();
3680
3681 app.apply(Action::OpenSelected);
3682 app.apply(Action::MoveDown);
3683
3684 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3685 assert!(!app.envelopes[1].flags.contains(MessageFlags::READ));
3686 assert!(app.pending_mutation_queue.is_empty());
3687
3688 app.expire_pending_preview_read_for_tests();
3689 app.tick();
3690
3691 assert!(!app.envelopes[0].flags.contains(MessageFlags::READ));
3692 assert!(app.envelopes[1].flags.contains(MessageFlags::READ));
3693 assert_eq!(app.pending_mutation_queue.len(), 1);
3694 match &app.pending_mutation_queue[0].0 {
3695 Request::Mutation(MutationCommand::SetRead { message_ids, read }) => {
3696 assert!(*read);
3697 assert_eq!(message_ids, &vec![app.envelopes[1].id.clone()]);
3698 }
3699 other => panic!("expected set-read mutation, got {other:?}"),
3700 }
3701 }
3702
3703 #[test]
3704 fn help_action_toggles_modal_state() {
3705 let mut app = App::new();
3706
3707 app.apply(Action::Help);
3708 assert!(app.help_modal_open);
3709
3710 app.apply(Action::Help);
3711 assert!(!app.help_modal_open);
3712 }
3713
3714 #[test]
3715 fn open_search_screen_activates_dedicated_search_workspace() {
3716 let mut app = App::new();
3717 app.apply(Action::OpenSearchScreen);
3718 assert_eq!(app.screen, Screen::Search);
3719 assert!(app.search_page.editing);
3720 }
3721
3722 #[test]
3723 fn search_screen_typing_updates_results_and_queues_search() {
3724 let mut app = App::new();
3725 let mut envelopes = make_test_envelopes(2);
3726 envelopes[0].subject = "crates.io release".into();
3727 envelopes[0].snippet = "mxr publish".into();
3728 envelopes[1].subject = "support request".into();
3729 envelopes[1].snippet = "billing".into();
3730 app.envelopes = envelopes.clone();
3731 app.all_envelopes = envelopes;
3732
3733 app.apply(Action::OpenSearchScreen);
3734 app.search_page.query.clear();
3735 app.search_page.results = app.all_envelopes.clone();
3736
3737 for ch in "crate".chars() {
3738 let action = app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
3739 assert!(action.is_none());
3740 }
3741
3742 assert_eq!(app.search_page.query, "crate");
3743 assert!(app.search_page.results.is_empty());
3744 assert!(app.search_page.loading_more);
3745 assert_eq!(
3746 app.pending_search,
3747 Some(PendingSearchRequest {
3748 query: "crate".into(),
3749 mode: mxr_core::SearchMode::Lexical,
3750 sort: mxr_core::SortOrder::DateDesc,
3751 limit: SEARCH_PAGE_SIZE,
3752 offset: 0,
3753 target: SearchTarget::SearchPage,
3754 append: false,
3755 session_id: app.search_page.session_id,
3756 })
3757 );
3758 }
3759
3760 #[test]
3761 fn open_search_screen_preserves_existing_search_session() {
3762 let mut app = App::new();
3763 let results = make_test_envelopes(2);
3764 app.search_bar.query = "stale overlay".into();
3765 app.search_page.query = "deploy".into();
3766 app.search_page.results = results.clone();
3767 app.search_page.session_active = true;
3768 app.search_page.selected_index = 1;
3769 app.search_page.active_pane = SearchPane::Preview;
3770 app.viewing_envelope = Some(results[1].clone());
3771
3772 app.apply(Action::OpenRulesScreen);
3773 app.apply(Action::OpenSearchScreen);
3774
3775 assert_eq!(app.screen, Screen::Search);
3776 assert_eq!(app.search_page.query, "deploy");
3777 assert_eq!(app.search_page.results.len(), 2);
3778 assert_eq!(app.search_page.selected_index, 1);
3779 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
3780 assert_eq!(
3781 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3782 Some(results[1].id.clone())
3783 );
3784 assert!(app.pending_search.is_none());
3785 }
3786
3787 #[test]
3788 fn search_open_selected_keeps_search_screen_and_focuses_preview() {
3789 let mut app = App::new();
3790 let results = make_test_envelopes(2);
3791 app.screen = Screen::Search;
3792 app.search_page.query = "deploy".into();
3793 app.search_page.results = results.clone();
3794 app.search_page.session_active = true;
3795 app.search_page.selected_index = 1;
3796
3797 app.apply(Action::OpenSelected);
3798
3799 assert_eq!(app.screen, Screen::Search);
3800 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
3801 assert_eq!(
3802 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
3803 Some(results[1].id.clone())
3804 );
3805 }
3806
3807 #[test]
3808 fn search_results_do_not_collapse_to_threads() {
3809 let mut app = App::new();
3810 let mut results = make_test_envelopes(3);
3811 let thread_id = ThreadId::new();
3812 for envelope in &mut results {
3813 envelope.thread_id = thread_id.clone();
3814 }
3815 app.mail_list_mode = MailListMode::Threads;
3816 app.screen = Screen::Search;
3817 app.search_page.query = "deploy".into();
3818 app.search_page.results = results.clone();
3819 app.search_page.session_active = true;
3820 app.search_page.selected_index = 1;
3821
3822 assert_eq!(app.search_row_count(), 3);
3823 assert_eq!(
3824 app.selected_search_envelope().map(|env| env.id.clone()),
3825 Some(results[1].id.clone())
3826 );
3827 }
3828
3829 #[test]
3830 fn search_jump_bottom_loads_remaining_pages() {
3831 let mut app = App::new();
3832 app.screen = Screen::Search;
3833 app.search_page.query = "deploy".into();
3834 app.search_page.results = make_test_envelopes(3);
3835 app.search_page.session_active = true;
3836 app.search_page.has_more = true;
3837 app.search_page.loading_more = false;
3838 app.search_page.session_id = 9;
3839
3840 app.apply(Action::JumpBottom);
3841
3842 assert!(app.search_page.load_to_end);
3843 assert!(app.search_page.loading_more);
3844 assert_eq!(
3845 app.pending_search,
3846 Some(PendingSearchRequest {
3847 query: "deploy".into(),
3848 mode: mxr_core::SearchMode::Lexical,
3849 sort: mxr_core::SortOrder::DateDesc,
3850 limit: SEARCH_PAGE_SIZE,
3851 offset: 3,
3852 target: SearchTarget::SearchPage,
3853 append: true,
3854 session_id: 9,
3855 })
3856 );
3857 }
3858
3859 #[test]
3860 fn search_escape_routes_back_to_inbox() {
3861 let mut app = App::new();
3862 app.screen = Screen::Search;
3863 app.search_page.session_active = true;
3864 app.search_page.query = "deploy".into();
3865 app.search_page.results = make_test_envelopes(2);
3866 app.search_page.active_pane = SearchPane::Results;
3867
3868 let action = app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3869
3870 assert_eq!(action, Some(Action::GoToInbox));
3871 }
3872
3873 #[test]
3874 fn open_rules_screen_marks_refresh_pending() {
3875 let mut app = App::new();
3876 app.apply(Action::OpenRulesScreen);
3877 assert_eq!(app.screen, Screen::Rules);
3878 assert!(app.rules_page.refresh_pending);
3879 }
3880
3881 #[test]
3882 fn open_diagnostics_screen_marks_refresh_pending() {
3883 let mut app = App::new();
3884 app.apply(Action::OpenDiagnosticsScreen);
3885 assert_eq!(app.screen, Screen::Diagnostics);
3886 assert!(app.diagnostics_page.refresh_pending);
3887 }
3888
3889 #[test]
3890 fn open_accounts_screen_marks_refresh_pending() {
3891 let mut app = App::new();
3892 app.apply(Action::OpenAccountsScreen);
3893 assert_eq!(app.screen, Screen::Accounts);
3894 assert!(app.accounts_page.refresh_pending);
3895 }
3896
3897 #[test]
3898 fn new_account_form_opens_from_accounts_screen() {
3899 let mut app = App::new();
3900 app.apply(Action::OpenAccountsScreen);
3901 app.apply(Action::OpenAccountFormNew);
3902
3903 assert_eq!(app.screen, Screen::Accounts);
3904 assert!(app.accounts_page.form.visible);
3905 assert_eq!(
3906 app.accounts_page.form.mode,
3907 crate::app::AccountFormMode::Gmail
3908 );
3909 }
3910
3911 #[test]
3912 fn app_from_empty_config_enters_account_onboarding() {
3913 let config = mxr_config::MxrConfig::default();
3914 let app = App::from_config(&config);
3915
3916 assert_eq!(app.screen, Screen::Accounts);
3917 assert!(app.accounts_page.refresh_pending);
3918 assert!(app.accounts_page.onboarding_required);
3919 assert!(app.accounts_page.onboarding_modal_open);
3920 }
3921
3922 #[test]
3923 fn onboarding_confirm_opens_new_account_form() {
3924 let config = mxr_config::MxrConfig::default();
3925 let mut app = App::from_config(&config);
3926
3927 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
3928
3929 assert_eq!(app.screen, Screen::Accounts);
3930 assert!(app.accounts_page.form.visible);
3931 assert!(!app.accounts_page.onboarding_modal_open);
3932 }
3933
3934 #[test]
3935 fn onboarding_q_quits() {
3936 let config = mxr_config::MxrConfig::default();
3937 let mut app = App::from_config(&config);
3938
3939 let action = app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
3940
3941 assert_eq!(action, Some(Action::QuitView));
3942 }
3943
3944 #[test]
3945 fn onboarding_blocks_mailbox_screen_until_account_exists() {
3946 let config = mxr_config::MxrConfig::default();
3947 let mut app = App::from_config(&config);
3948
3949 app.apply(Action::OpenMailboxScreen);
3950
3951 assert_eq!(app.screen, Screen::Accounts);
3952 assert!(app.accounts_page.onboarding_required);
3953 }
3954
3955 #[test]
3956 fn account_form_h_and_l_switch_modes_from_any_field() {
3957 let mut app = App::new();
3958 app.apply(Action::OpenAccountFormNew);
3959 app.accounts_page.form.active_field = 2;
3960
3961 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
3962 assert_eq!(
3963 app.accounts_page.form.mode,
3964 crate::app::AccountFormMode::ImapSmtp
3965 );
3966
3967 app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
3968 assert_eq!(
3969 app.accounts_page.form.mode,
3970 crate::app::AccountFormMode::Gmail
3971 );
3972 }
3973
3974 #[test]
3975 fn account_form_tab_on_mode_cycles_modes() {
3976 let mut app = App::new();
3977 app.apply(Action::OpenAccountFormNew);
3978 app.accounts_page.form.active_field = 0;
3979
3980 app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
3981 assert_eq!(
3982 app.accounts_page.form.mode,
3983 crate::app::AccountFormMode::ImapSmtp
3984 );
3985
3986 app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
3987 assert_eq!(
3988 app.accounts_page.form.mode,
3989 crate::app::AccountFormMode::Gmail
3990 );
3991 }
3992
3993 #[test]
3994 fn account_form_mode_switch_with_input_requires_confirmation() {
3995 let mut app = App::new();
3996 app.apply(Action::OpenAccountFormNew);
3997 app.accounts_page.form.key = "work".into();
3998
3999 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
4000
4001 assert_eq!(
4002 app.accounts_page.form.mode,
4003 crate::app::AccountFormMode::Gmail
4004 );
4005 assert_eq!(
4006 app.accounts_page.form.pending_mode_switch,
4007 Some(crate::app::AccountFormMode::ImapSmtp)
4008 );
4009 }
4010
4011 #[test]
4012 fn account_form_mode_switch_confirmation_applies_mode_change() {
4013 let mut app = App::new();
4014 app.apply(Action::OpenAccountFormNew);
4015 app.accounts_page.form.key = "work".into();
4016
4017 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
4018 app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4019
4020 assert_eq!(
4021 app.accounts_page.form.mode,
4022 crate::app::AccountFormMode::ImapSmtp
4023 );
4024 assert!(app.accounts_page.form.pending_mode_switch.is_none());
4025 }
4026
4027 #[test]
4028 fn account_form_mode_switch_confirmation_cancel_keeps_mode() {
4029 let mut app = App::new();
4030 app.apply(Action::OpenAccountFormNew);
4031 app.accounts_page.form.key = "work".into();
4032
4033 app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
4034 app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
4035
4036 assert_eq!(
4037 app.accounts_page.form.mode,
4038 crate::app::AccountFormMode::Gmail
4039 );
4040 assert!(app.accounts_page.form.pending_mode_switch.is_none());
4041 }
4042
4043 #[test]
4044 fn flattened_sidebar_navigation_reaches_saved_searches() {
4045 let mut app = App::new();
4046 app.labels = vec![Label {
4047 id: LabelId::new(),
4048 account_id: AccountId::new(),
4049 provider_id: "inbox".into(),
4050 name: "INBOX".into(),
4051 kind: LabelKind::System,
4052 color: None,
4053 unread_count: 1,
4054 total_count: 3,
4055 }];
4056 app.saved_searches = vec![SavedSearch {
4057 id: SavedSearchId::new(),
4058 account_id: None,
4059 name: "Unread".into(),
4060 query: "is:unread".into(),
4061 search_mode: SearchMode::Lexical,
4062 sort: SortOrder::DateDesc,
4063 icon: None,
4064 position: 0,
4065 created_at: chrono::Utc::now(),
4066 }];
4067 app.active_pane = ActivePane::Sidebar;
4068
4069 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4070 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4071 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
4072
4073 assert!(matches!(
4074 app.selected_sidebar_item(),
4075 Some(super::app::SidebarItem::SavedSearch(_))
4076 ));
4077 }
4078
4079 #[test]
4080 fn toggle_select_advances_cursor_and_updates_preview() {
4081 let mut app = App::new();
4082 app.envelopes = make_test_envelopes(2);
4083 app.all_envelopes = app.envelopes.clone();
4084 app.apply(Action::OpenSelected);
4085
4086 app.apply(Action::ToggleSelect);
4087
4088 assert_eq!(app.selected_index, 1);
4089 assert_eq!(
4090 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4091 Some(app.envelopes[1].id.clone())
4092 );
4093 assert!(matches!(
4094 app.body_view_state,
4095 BodyViewState::Loading { ref preview }
4096 if preview.as_deref() == Some("Snippet 1")
4097 ));
4098 }
4099
4100 #[test]
4101 fn label_count_updates_preserve_sidebar_selection_identity() {
4102 let mut app = App::new();
4103 app.labels = make_test_labels();
4104
4105 let selected_index = app
4106 .sidebar_items()
4107 .iter()
4108 .position(|item| matches!(item, super::app::SidebarItem::Label(label) if label.name == "Work"))
4109 .unwrap();
4110 app.sidebar_selected = selected_index;
4111
4112 handle_daemon_event(
4113 &mut app,
4114 DaemonEvent::LabelCountsUpdated {
4115 counts: vec![
4116 LabelCount {
4117 label_id: LabelId::from_provider_id("test", "STARRED"),
4118 unread_count: 0,
4119 total_count: 0,
4120 },
4121 LabelCount {
4122 label_id: LabelId::from_provider_id("test", "SENT"),
4123 unread_count: 0,
4124 total_count: 0,
4125 },
4126 ],
4127 },
4128 );
4129
4130 assert!(matches!(
4131 app.selected_sidebar_item(),
4132 Some(super::app::SidebarItem::Label(label)) if label.name == "Work"
4133 ));
4134 }
4135
4136 #[test]
4137 fn opening_search_result_keeps_search_workspace_open() {
4138 let mut app = App::new();
4139 app.screen = Screen::Search;
4140 app.search_page.results = make_test_envelopes(2);
4141 app.search_page.selected_index = 1;
4142
4143 app.apply(Action::OpenSelected);
4144
4145 assert_eq!(app.screen, Screen::Search);
4146 assert_eq!(app.search_page.active_pane, SearchPane::Preview);
4147 assert_eq!(
4148 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4149 Some(app.search_page.results[1].id.clone())
4150 );
4151 }
4152
4153 #[test]
4154 fn attachment_list_opens_modal_for_current_message() {
4155 let mut app = App::new();
4156 app.envelopes = make_test_envelopes(1);
4157 app.all_envelopes = app.envelopes.clone();
4158 let env = app.envelopes[0].clone();
4159 app.body_cache.insert(
4160 env.id.clone(),
4161 MessageBody {
4162 message_id: env.id.clone(),
4163 text_plain: Some("hello".into()),
4164 text_html: None,
4165 attachments: vec![AttachmentMeta {
4166 id: AttachmentId::new(),
4167 message_id: env.id.clone(),
4168 filename: "report.pdf".into(),
4169 mime_type: "application/pdf".into(),
4170 size_bytes: 1024,
4171 local_path: None,
4172 provider_id: "att-1".into(),
4173 }],
4174 fetched_at: chrono::Utc::now(),
4175 metadata: Default::default(),
4176 },
4177 );
4178
4179 app.apply(Action::OpenSelected);
4180 app.apply(Action::AttachmentList);
4181
4182 assert!(app.attachment_panel.visible);
4183 assert_eq!(app.attachment_panel.attachments.len(), 1);
4184 assert_eq!(app.attachment_panel.attachments[0].filename, "report.pdf");
4185 }
4186
4187 #[test]
4188 fn unchanged_editor_result_disables_send_actions() {
4189 let temp = std::env::temp_dir().join(format!(
4190 "mxr-compose-test-{}-{}.md",
4191 std::process::id(),
4192 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
4193 ));
4194 let content = "---\nto: a@example.com\ncc: \"\"\nbcc: \"\"\nsubject: Hello\nfrom: me@example.com\nattach: []\n---\n\nBody\n";
4195 std::fs::write(&temp, content).unwrap();
4196
4197 let pending = pending_send_from_edited_draft(&ComposeReadyData {
4198 draft_path: temp.clone(),
4199 cursor_line: 1,
4200 initial_content: content.to_string(),
4201 })
4202 .unwrap()
4203 .expect("pending send should exist");
4204
4205 assert!(!pending.allow_send);
4206
4207 let _ = std::fs::remove_file(temp);
4208 }
4209
4210 #[test]
4211 fn send_key_is_ignored_for_unchanged_draft_confirmation() {
4212 let mut app = App::new();
4213 app.pending_send_confirm = Some(PendingSend {
4214 fm: mxr_compose::frontmatter::ComposeFrontmatter {
4215 to: "a@example.com".into(),
4216 cc: String::new(),
4217 bcc: String::new(),
4218 subject: "Hello".into(),
4219 from: "me@example.com".into(),
4220 in_reply_to: None,
4221 references: vec![],
4222 attach: vec![],
4223 },
4224 body: "Body".into(),
4225 draft_path: std::path::PathBuf::from("/tmp/draft.md"),
4226 allow_send: false,
4227 });
4228
4229 let _ = app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
4230
4231 assert!(app.pending_send_confirm.is_some());
4232 assert!(app.pending_mutation_queue.is_empty());
4233 }
4234
4235 #[test]
4236 fn mail_list_l_opens_label_picker_not_message() {
4237 let mut app = App::new();
4238 app.active_pane = ActivePane::MailList;
4239
4240 let action = app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
4241
4242 assert_eq!(action, Some(Action::ApplyLabel));
4243 }
4244
4245 #[test]
4246 fn input_gc_opens_config_editor() {
4247 let mut h = InputHandler::new();
4248
4249 assert_eq!(
4250 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
4251 None
4252 );
4253 assert_eq!(
4254 h.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)),
4255 Some(Action::EditConfig)
4256 );
4257 }
4258
4259 #[test]
4260 fn input_g_shift_l_opens_logs() {
4261 let mut h = InputHandler::new();
4262
4263 assert_eq!(
4264 h.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)),
4265 None
4266 );
4267 assert_eq!(
4268 h.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT)),
4269 Some(Action::OpenLogs)
4270 );
4271 }
4272
4273 #[test]
4274 fn input_m_marks_read_and_archives() {
4275 let mut app = App::new();
4276
4277 let action = app.handle_key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
4278
4279 assert_eq!(action, Some(Action::MarkReadAndArchive));
4280 }
4281
4282 #[test]
4283 fn reconnect_detection_treats_connection_refused_as_recoverable() {
4284 let result = Err(MxrError::Ipc(
4285 "IPC error: Connection refused (os error 61)".into(),
4286 ));
4287
4288 assert!(crate::should_reconnect_ipc(&result));
4289 }
4290
4291 #[test]
4292 fn autostart_detection_handles_refused_and_missing_socket() {
4293 let refused = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
4294 let missing = std::io::Error::from(std::io::ErrorKind::NotFound);
4295 let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
4296
4297 assert!(crate::should_autostart_daemon(&refused));
4298 assert!(crate::should_autostart_daemon(&missing));
4299 assert!(!crate::should_autostart_daemon(&other));
4300 }
4301
4302 #[test]
4303 fn diagnostics_shift_l_opens_logs() {
4304 let mut app = App::new();
4305 app.screen = Screen::Diagnostics;
4306
4307 let action = app.handle_key(KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT));
4308
4309 assert_eq!(action, Some(Action::OpenLogs));
4310 }
4311
4312 #[test]
4313 fn diagnostics_tab_cycles_selected_pane() {
4314 let mut app = App::new();
4315 app.screen = Screen::Diagnostics;
4316
4317 let action = app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
4318
4319 assert!(action.is_none());
4320 assert_eq!(
4321 app.diagnostics_page.selected_pane,
4322 crate::app::DiagnosticsPaneKind::Data
4323 );
4324 }
4325
4326 #[test]
4327 fn diagnostics_enter_toggles_fullscreen_for_selected_pane() {
4328 let mut app = App::new();
4329 app.screen = Screen::Diagnostics;
4330 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Logs;
4331
4332 assert!(app
4333 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4334 .is_none());
4335 assert_eq!(
4336 app.diagnostics_page.fullscreen_pane,
4337 Some(crate::app::DiagnosticsPaneKind::Logs)
4338 );
4339 assert!(app
4340 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
4341 .is_none());
4342 assert_eq!(app.diagnostics_page.fullscreen_pane, None);
4343 }
4344
4345 #[test]
4346 fn diagnostics_d_opens_selected_pane_details() {
4347 let mut app = App::new();
4348 app.screen = Screen::Diagnostics;
4349 app.diagnostics_page.selected_pane = crate::app::DiagnosticsPaneKind::Events;
4350
4351 let action = app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
4352
4353 assert_eq!(action, Some(Action::OpenDiagnosticsPaneDetails));
4354 }
4355
4356 #[test]
4357 fn back_clears_selection_before_other_mail_list_back_behavior() {
4358 let mut app = App::new();
4359 app.envelopes = make_test_envelopes(2);
4360 app.all_envelopes = app.envelopes.clone();
4361 app.selected_set.insert(app.envelopes[0].id.clone());
4362
4363 app.apply(Action::Back);
4364
4365 assert!(app.selected_set.is_empty());
4366 assert_eq!(app.status_message.as_deref(), Some("Selection cleared"));
4367 }
4368
4369 #[test]
4370 fn bulk_archive_requires_confirmation_before_queueing() {
4371 let mut app = App::new();
4372 app.envelopes = make_test_envelopes(3);
4373 app.all_envelopes = app.envelopes.clone();
4374 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
4375
4376 app.apply(Action::Archive);
4377
4378 assert!(app.pending_mutation_queue.is_empty());
4379 assert!(app.pending_bulk_confirm.is_some());
4380 }
4381
4382 #[test]
4383 fn confirming_bulk_archive_queues_mutation_and_clears_selection() {
4384 let mut app = App::new();
4385 app.envelopes = make_test_envelopes(3);
4386 app.all_envelopes = app.envelopes.clone();
4387 app.selected_set = app.envelopes.iter().map(|env| env.id.clone()).collect();
4388 app.apply(Action::Archive);
4389
4390 app.apply(Action::OpenSelected);
4391
4392 assert!(app.pending_bulk_confirm.is_none());
4393 assert_eq!(app.pending_mutation_queue.len(), 1);
4394 assert!(app.selected_set.is_empty());
4395 }
4396
4397 #[test]
4398 fn command_palette_includes_major_mail_actions() {
4399 let labels: Vec<String> = default_commands()
4400 .into_iter()
4401 .map(|cmd| cmd.label)
4402 .collect();
4403 assert!(labels.contains(&"Reply".to_string()));
4404 assert!(labels.contains(&"Reply All".to_string()));
4405 assert!(labels.contains(&"Archive".to_string()));
4406 assert!(labels.contains(&"Delete".to_string()));
4407 assert!(labels.contains(&"Apply Label".to_string()));
4408 assert!(labels.contains(&"Snooze".to_string()));
4409 assert!(labels.contains(&"Clear Selection".to_string()));
4410 assert!(labels.contains(&"Open Accounts Page".to_string()));
4411 assert!(labels.contains(&"New IMAP/SMTP Account".to_string()));
4412 assert!(labels.contains(&"Set Default Account".to_string()));
4413 assert!(labels.contains(&"Edit Config".to_string()));
4414 }
4415
4416 #[test]
4417 fn local_label_changes_update_open_message() {
4418 let mut app = App::new();
4419 app.labels = make_test_labels();
4420 app.envelopes = make_test_envelopes(1);
4421 app.all_envelopes = app.envelopes.clone();
4422 app.apply(Action::OpenSelected);
4423
4424 let user_label = app
4425 .labels
4426 .iter()
4427 .find(|label| label.name == "Work")
4428 .unwrap()
4429 .clone();
4430 let message_id = app.envelopes[0].id.clone();
4431
4432 app.apply_local_label_refs(
4433 std::slice::from_ref(&message_id),
4434 std::slice::from_ref(&user_label.name),
4435 &[],
4436 );
4437
4438 assert!(app
4439 .viewing_envelope
4440 .as_ref()
4441 .unwrap()
4442 .label_provider_ids
4443 .contains(&user_label.provider_id));
4444 }
4445
4446 #[test]
4447 fn snooze_action_opens_modal_then_queues_request() {
4448 let mut app = App::new();
4449 app.envelopes = make_test_envelopes(1);
4450 app.all_envelopes = app.envelopes.clone();
4451
4452 app.apply(Action::Snooze);
4453 assert!(app.snooze_panel.visible);
4454
4455 app.apply(Action::Snooze);
4456 assert!(!app.snooze_panel.visible);
4457 assert_eq!(app.pending_mutation_queue.len(), 1);
4458 match &app.pending_mutation_queue[0].0 {
4459 Request::Snooze {
4460 message_id,
4461 wake_at,
4462 } => {
4463 assert_eq!(message_id, &app.envelopes[0].id);
4464 assert!(*wake_at > chrono::Utc::now());
4465 }
4466 other => panic!("expected snooze request, got {other:?}"),
4467 }
4468 }
4469
4470 #[test]
4471 fn open_selected_cache_miss_enters_loading_with_snippet_preview() {
4472 let mut app = App::new();
4473 app.envelopes = make_test_envelopes(1);
4474 app.all_envelopes = app.envelopes.clone();
4475
4476 app.apply(Action::OpenSelected);
4477
4478 assert!(matches!(
4479 app.body_view_state,
4480 BodyViewState::Loading { ref preview }
4481 if preview.as_deref() == Some("Snippet 0")
4482 ));
4483 assert_eq!(app.queued_body_fetches, vec![app.envelopes[0].id.clone()]);
4484 assert!(app.in_flight_body_requests.contains(&app.envelopes[0].id));
4485 }
4486
4487 #[test]
4488 fn cached_plain_body_resolves_ready_state() {
4489 let mut app = App::new();
4490 app.envelopes = make_test_envelopes(1);
4491 app.all_envelopes = app.envelopes.clone();
4492 let env = app.envelopes[0].clone();
4493
4494 app.body_cache.insert(
4495 env.id.clone(),
4496 MessageBody {
4497 message_id: env.id.clone(),
4498 text_plain: Some("Plain body".into()),
4499 text_html: None,
4500 attachments: vec![],
4501 fetched_at: chrono::Utc::now(),
4502 metadata: Default::default(),
4503 },
4504 );
4505
4506 app.apply(Action::OpenSelected);
4507
4508 assert!(matches!(
4509 app.body_view_state,
4510 BodyViewState::Ready {
4511 ref raw,
4512 ref rendered,
4513 source: BodySource::Plain,
4514 } if raw == "Plain body" && rendered == "Plain body"
4515 ));
4516 }
4517
4518 #[test]
4519 fn cached_html_only_body_resolves_ready_state() {
4520 let mut app = App::new();
4521 app.envelopes = make_test_envelopes(1);
4522 app.all_envelopes = app.envelopes.clone();
4523 let env = app.envelopes[0].clone();
4524
4525 app.body_cache.insert(
4526 env.id.clone(),
4527 MessageBody {
4528 message_id: env.id.clone(),
4529 text_plain: None,
4530 text_html: Some("<p>Hello html</p>".into()),
4531 attachments: vec![],
4532 fetched_at: chrono::Utc::now(),
4533 metadata: Default::default(),
4534 },
4535 );
4536
4537 app.apply(Action::OpenSelected);
4538
4539 assert!(matches!(
4540 app.body_view_state,
4541 BodyViewState::Ready {
4542 ref raw,
4543 ref rendered,
4544 source: BodySource::Html,
4545 } if raw == "<p>Hello html</p>"
4546 && rendered.contains("Hello html")
4547 && !rendered.contains("<p>")
4548 ));
4549 }
4550
4551 #[test]
4552 fn cached_empty_body_resolves_empty_not_loading() {
4553 let mut app = App::new();
4554 app.envelopes = make_test_envelopes(1);
4555 app.all_envelopes = app.envelopes.clone();
4556 let env = app.envelopes[0].clone();
4557
4558 app.body_cache.insert(
4559 env.id.clone(),
4560 MessageBody {
4561 message_id: env.id.clone(),
4562 text_plain: None,
4563 text_html: None,
4564 attachments: vec![],
4565 fetched_at: chrono::Utc::now(),
4566 metadata: Default::default(),
4567 },
4568 );
4569
4570 app.apply(Action::OpenSelected);
4571
4572 assert!(matches!(
4573 app.body_view_state,
4574 BodyViewState::Empty { ref preview }
4575 if preview.as_deref() == Some("Snippet 0")
4576 ));
4577 }
4578
4579 #[test]
4580 fn body_fetch_error_resolves_error_not_loading() {
4581 let mut app = App::new();
4582 app.envelopes = make_test_envelopes(1);
4583 app.all_envelopes = app.envelopes.clone();
4584 app.apply(Action::OpenSelected);
4585 let env = app.envelopes[0].clone();
4586
4587 app.resolve_body_fetch_error(&env.id, "boom".into());
4588
4589 assert!(matches!(
4590 app.body_view_state,
4591 BodyViewState::Error { ref message, ref preview }
4592 if message == "boom" && preview.as_deref() == Some("Snippet 0")
4593 ));
4594 assert!(!app.in_flight_body_requests.contains(&env.id));
4595 }
4596
4597 #[test]
4598 fn stale_body_response_does_not_clobber_current_view() {
4599 let mut app = App::new();
4600 app.envelopes = make_test_envelopes(2);
4601 app.all_envelopes = app.envelopes.clone();
4602
4603 app.apply(Action::OpenSelected);
4604 let first = app.envelopes[0].clone();
4605 app.active_pane = ActivePane::MailList;
4606 app.apply(Action::MoveDown);
4607 let second = app.envelopes[1].clone();
4608
4609 app.resolve_body_success(MessageBody {
4610 message_id: first.id.clone(),
4611 text_plain: Some("Old body".into()),
4612 text_html: None,
4613 attachments: vec![],
4614 fetched_at: chrono::Utc::now(),
4615 metadata: Default::default(),
4616 });
4617
4618 assert_eq!(
4619 app.viewing_envelope.as_ref().map(|env| env.id.clone()),
4620 Some(second.id)
4621 );
4622 assert!(matches!(
4623 app.body_view_state,
4624 BodyViewState::Loading { ref preview }
4625 if preview.as_deref() == Some("Snippet 1")
4626 ));
4627 }
4628
4629 #[test]
4630 fn reader_mode_toggle_shows_raw_html_when_disabled() {
4631 let mut app = App::new();
4632 app.envelopes = make_test_envelopes(1);
4633 app.all_envelopes = app.envelopes.clone();
4634 let env = app.envelopes[0].clone();
4635 app.body_cache.insert(
4636 env.id.clone(),
4637 MessageBody {
4638 message_id: env.id.clone(),
4639 text_plain: None,
4640 text_html: Some("<p>Hello html</p>".into()),
4641 attachments: vec![],
4642 fetched_at: chrono::Utc::now(),
4643 metadata: Default::default(),
4644 },
4645 );
4646
4647 app.apply(Action::OpenSelected);
4648
4649 match &app.body_view_state {
4650 BodyViewState::Ready { raw, rendered, .. } => {
4651 assert_eq!(raw, "<p>Hello html</p>");
4652 assert_ne!(rendered, raw);
4653 assert!(rendered.contains("Hello html"));
4654 }
4655 other => panic!("expected ready state, got {other:?}"),
4656 }
4657
4658 app.apply(Action::ToggleReaderMode);
4659
4660 match &app.body_view_state {
4661 BodyViewState::Ready { raw, rendered, .. } => {
4662 assert_eq!(raw, "<p>Hello html</p>");
4663 assert_eq!(rendered, raw);
4664 }
4665 other => panic!("expected ready state, got {other:?}"),
4666 }
4667
4668 app.apply(Action::ToggleReaderMode);
4669
4670 match &app.body_view_state {
4671 BodyViewState::Ready { raw, rendered, .. } => {
4672 assert_eq!(raw, "<p>Hello html</p>");
4673 assert_ne!(rendered, raw);
4674 assert!(rendered.contains("Hello html"));
4675 }
4676 other => panic!("expected ready state, got {other:?}"),
4677 }
4678 }
4679}