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