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