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