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