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