Skip to main content

mxr_tui/
lib.rs

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