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