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