Skip to main content

mxr_tui/
lib.rs

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