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