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