Skip to main content

mxr_tui/
lib.rs

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