Skip to main content

mxr_tui/
lib.rs

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