1mod actions;
2mod draw;
3mod input;
4use crate::action::{Action, PatternKind};
5use crate::client::Client;
6use crate::input::InputHandler;
7use crate::theme::Theme;
8use crate::ui;
9use crate::ui::command_palette::CommandPalette;
10use crate::ui::compose_picker::ComposePicker;
11use crate::ui::label_picker::{LabelPicker, LabelPickerMode};
12use crate::ui::search_bar::SearchBar;
13use crossterm::event::{KeyCode, KeyModifiers};
14use mxr_config::RenderConfig;
15use mxr_core::id::{AccountId, AttachmentId, MessageId};
16use mxr_core::types::*;
17use mxr_core::MxrError;
18use mxr_protocol::{MutationCommand, Request, Response, ResponseData};
19use ratatui::prelude::*;
20use std::collections::{HashMap, HashSet};
21use std::time::{Duration, Instant};
22
23const PREVIEW_MARK_READ_DELAY: Duration = Duration::from_secs(5);
24
25fn sane_mail_sort_timestamp(date: &chrono::DateTime<chrono::Utc>) -> i64 {
26 let cutoff = (chrono::Utc::now() + chrono::Duration::days(1)).timestamp();
27 let timestamp = date.timestamp();
28 if timestamp > cutoff {
29 0
30 } else {
31 timestamp
32 }
33}
34
35#[derive(Debug, Clone)]
36pub enum MutationEffect {
37 RemoveFromList(MessageId),
38 RemoveFromListMany(Vec<MessageId>),
39 UpdateFlags {
40 message_id: MessageId,
41 flags: MessageFlags,
42 },
43 UpdateFlagsMany {
44 updates: Vec<(MessageId, MessageFlags)>,
45 },
46 ModifyLabels {
47 message_ids: Vec<MessageId>,
48 add: Vec<String>,
49 remove: Vec<String>,
50 status: String,
51 },
52 RefreshList,
53 StatusOnly(String),
54}
55
56pub struct PendingSend {
58 pub fm: mxr_compose::frontmatter::ComposeFrontmatter,
59 pub body: String,
60 pub draft_path: std::path::PathBuf,
61 pub allow_send: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum ComposeAction {
66 New,
67 NewWithTo(String),
68 EditDraft(std::path::PathBuf),
69 Reply { message_id: MessageId },
70 ReplyAll { message_id: MessageId },
71 Forward { message_id: MessageId },
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum ActivePane {
76 Sidebar,
77 MailList,
78 MessageView,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum MailListMode {
83 Threads,
84 Messages,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum MailboxView {
89 Messages,
90 Subscriptions,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Screen {
95 Mailbox,
96 Search,
97 Rules,
98 Diagnostics,
99 Accounts,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum SidebarSection {
104 Labels,
105 SavedSearches,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum LayoutMode {
110 TwoPane,
111 ThreePane,
112 FullScreen,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum BodySource {
117 Plain,
118 Html,
119 Snippet,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum BodyViewState {
124 Loading {
125 preview: Option<String>,
126 },
127 Ready {
128 raw: String,
129 rendered: String,
130 source: BodySource,
131 },
132 Empty {
133 preview: Option<String>,
134 },
135 Error {
136 message: String,
137 preview: Option<String>,
138 },
139}
140
141#[derive(Debug, Clone)]
142pub struct MailListRow {
143 pub thread_id: mxr_core::ThreadId,
144 pub representative: Envelope,
145 pub message_count: usize,
146 pub unread_count: usize,
147}
148
149#[derive(Debug, Clone)]
150pub struct SubscriptionEntry {
151 pub summary: SubscriptionSummary,
152 pub envelope: Envelope,
153}
154
155#[derive(Debug, Clone)]
156pub enum SidebarItem {
157 AllMail,
158 Subscriptions,
159 Label(Label),
160 SavedSearch(mxr_core::SavedSearch),
161}
162
163#[derive(Debug, Clone, Default)]
164pub struct SubscriptionsPageState {
165 pub entries: Vec<SubscriptionEntry>,
166}
167
168#[derive(Debug, Clone, Default)]
169pub struct SearchPageState {
170 pub query: String,
171 pub editing: bool,
172 pub results: Vec<Envelope>,
173 pub scores: HashMap<MessageId, f32>,
174 pub selected_index: usize,
175 pub scroll_offset: usize,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum RulesPanel {
180 Details,
181 History,
182 DryRun,
183 Form,
184}
185
186#[derive(Debug, Clone, Default)]
187pub struct RuleFormState {
188 pub visible: bool,
189 pub existing_rule: Option<String>,
190 pub name: String,
191 pub condition: String,
192 pub action: String,
193 pub priority: String,
194 pub enabled: bool,
195 pub active_field: usize,
196}
197
198#[derive(Debug, Clone)]
199pub struct RulesPageState {
200 pub rules: Vec<serde_json::Value>,
201 pub selected_index: usize,
202 pub detail: Option<serde_json::Value>,
203 pub history: Vec<serde_json::Value>,
204 pub dry_run: Vec<serde_json::Value>,
205 pub panel: RulesPanel,
206 pub status: Option<String>,
207 pub refresh_pending: bool,
208 pub form: RuleFormState,
209}
210
211impl Default for RulesPageState {
212 fn default() -> Self {
213 Self {
214 rules: Vec::new(),
215 selected_index: 0,
216 detail: None,
217 history: Vec::new(),
218 dry_run: Vec::new(),
219 panel: RulesPanel::Details,
220 status: None,
221 refresh_pending: false,
222 form: RuleFormState {
223 enabled: true,
224 priority: "100".to_string(),
225 ..RuleFormState::default()
226 },
227 }
228 }
229}
230
231#[derive(Debug, Clone, Default)]
232pub struct DiagnosticsPageState {
233 pub uptime_secs: Option<u64>,
234 pub daemon_pid: Option<u32>,
235 pub accounts: Vec<String>,
236 pub total_messages: Option<u32>,
237 pub sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
238 pub doctor: Option<mxr_protocol::DoctorReport>,
239 pub events: Vec<mxr_protocol::EventLogEntry>,
240 pub logs: Vec<String>,
241 pub status: Option<String>,
242 pub refresh_pending: bool,
243 pub pending_requests: u8,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum AccountFormMode {
248 Gmail,
249 ImapSmtp,
250 SmtpOnly,
251}
252
253#[derive(Debug, Clone)]
254pub struct AccountFormState {
255 pub visible: bool,
256 pub mode: AccountFormMode,
257 pub pending_mode_switch: Option<AccountFormMode>,
258 pub key: String,
259 pub name: String,
260 pub email: String,
261 pub gmail_credential_source: mxr_protocol::GmailCredentialSourceData,
262 pub gmail_client_id: String,
263 pub gmail_client_secret: String,
264 pub gmail_token_ref: String,
265 pub gmail_authorized: bool,
266 pub imap_host: String,
267 pub imap_port: String,
268 pub imap_username: String,
269 pub imap_password_ref: String,
270 pub imap_password: String,
271 pub smtp_host: String,
272 pub smtp_port: String,
273 pub smtp_username: String,
274 pub smtp_password_ref: String,
275 pub smtp_password: String,
276 pub active_field: usize,
277 pub editing_field: bool,
278 pub field_cursor: usize,
279 pub last_result: Option<mxr_protocol::AccountOperationResult>,
280}
281
282impl Default for AccountFormState {
283 fn default() -> Self {
284 Self {
285 visible: false,
286 mode: AccountFormMode::Gmail,
287 pending_mode_switch: None,
288 key: String::new(),
289 name: String::new(),
290 email: String::new(),
291 gmail_credential_source: mxr_protocol::GmailCredentialSourceData::Bundled,
292 gmail_client_id: String::new(),
293 gmail_client_secret: String::new(),
294 gmail_token_ref: String::new(),
295 gmail_authorized: false,
296 imap_host: String::new(),
297 imap_port: "993".into(),
298 imap_username: String::new(),
299 imap_password_ref: String::new(),
300 imap_password: String::new(),
301 smtp_host: String::new(),
302 smtp_port: "587".into(),
303 smtp_username: String::new(),
304 smtp_password_ref: String::new(),
305 smtp_password: String::new(),
306 active_field: 0,
307 editing_field: false,
308 field_cursor: 0,
309 last_result: None,
310 }
311 }
312}
313
314#[derive(Debug, Clone, Default)]
315pub struct AccountsPageState {
316 pub accounts: Vec<mxr_protocol::AccountSummaryData>,
317 pub selected_index: usize,
318 pub status: Option<String>,
319 pub last_result: Option<mxr_protocol::AccountOperationResult>,
320 pub refresh_pending: bool,
321 pub onboarding_required: bool,
322 pub onboarding_modal_open: bool,
323 pub form: AccountFormState,
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum AttachmentOperation {
328 Open,
329 Download,
330}
331
332#[derive(Debug, Clone, Default)]
333pub struct AttachmentPanelState {
334 pub visible: bool,
335 pub message_id: Option<MessageId>,
336 pub attachments: Vec<AttachmentMeta>,
337 pub selected_index: usize,
338 pub status: Option<String>,
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum SnoozePreset {
343 TomorrowMorning,
344 Tonight,
345 Weekend,
346 NextMonday,
347}
348
349#[derive(Debug, Clone, Default)]
350pub struct SnoozePanelState {
351 pub visible: bool,
352 pub selected_index: usize,
353}
354
355#[derive(Debug, Clone)]
356pub struct PendingAttachmentAction {
357 pub message_id: MessageId,
358 pub attachment_id: AttachmentId,
359 pub operation: AttachmentOperation,
360}
361
362#[derive(Debug, Clone)]
363pub struct PendingBulkConfirm {
364 pub title: String,
365 pub detail: String,
366 pub request: Request,
367 pub effect: MutationEffect,
368 pub optimistic_effect: Option<MutationEffect>,
369 pub status_message: String,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct ErrorModalState {
374 pub title: String,
375 pub detail: String,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct AttachmentSummary {
380 pub filename: String,
381 pub size_bytes: u64,
382}
383
384#[derive(Debug, Clone)]
385pub struct PendingUnsubscribeConfirm {
386 pub message_id: MessageId,
387 pub account_id: AccountId,
388 pub sender_email: String,
389 pub method_label: String,
390 pub archive_message_ids: Vec<MessageId>,
391}
392
393#[derive(Debug, Clone)]
394pub struct PendingUnsubscribeAction {
395 pub message_id: MessageId,
396 pub archive_message_ids: Vec<MessageId>,
397 pub sender_email: String,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401enum SidebarGroup {
402 SystemLabels,
403 UserLabels,
404 SavedSearches,
405}
406
407impl BodyViewState {
408 pub fn display_text(&self) -> Option<&str> {
409 match self {
410 Self::Ready { rendered, .. } => Some(rendered.as_str()),
411 Self::Loading { preview } => preview.as_deref(),
412 Self::Empty { preview } => preview.as_deref(),
413 Self::Error { preview, .. } => preview.as_deref(),
414 }
415 }
416}
417
418#[derive(Debug, Clone)]
419struct PendingPreviewRead {
420 message_id: MessageId,
421 due_at: Instant,
422}
423
424pub struct App {
425 pub theme: Theme,
426 pub envelopes: Vec<Envelope>,
427 pub all_envelopes: Vec<Envelope>,
428 pub mailbox_view: MailboxView,
429 pub labels: Vec<Label>,
430 pub screen: Screen,
431 pub mail_list_mode: MailListMode,
432 pub selected_index: usize,
433 pub scroll_offset: usize,
434 pub active_pane: ActivePane,
435 pub should_quit: bool,
436 pub layout_mode: LayoutMode,
437 pub search_bar: SearchBar,
438 pub search_page: SearchPageState,
439 pub command_palette: CommandPalette,
440 pub body_view_state: BodyViewState,
441 pub viewing_envelope: Option<Envelope>,
442 pub viewed_thread: Option<Thread>,
443 pub viewed_thread_messages: Vec<Envelope>,
444 pub thread_selected_index: usize,
445 pub message_scroll_offset: u16,
446 pub last_sync_status: Option<String>,
447 pub visible_height: usize,
448 pub body_cache: HashMap<MessageId, MessageBody>,
449 pub queued_body_fetches: Vec<MessageId>,
450 pub in_flight_body_requests: HashSet<MessageId>,
451 pub pending_thread_fetch: Option<mxr_core::ThreadId>,
452 pub in_flight_thread_fetch: Option<mxr_core::ThreadId>,
453 pub pending_search: Option<(String, SearchMode)>,
454 pub search_active: bool,
455 pub pending_rule_detail: Option<String>,
456 pub pending_rule_history: Option<String>,
457 pub pending_rule_dry_run: Option<String>,
458 pub pending_rule_delete: Option<String>,
459 pub pending_rule_upsert: Option<serde_json::Value>,
460 pub pending_rule_form_load: Option<String>,
461 pub pending_rule_form_save: bool,
462 pub pending_bug_report: bool,
463 pub pending_account_save: Option<mxr_protocol::AccountConfigData>,
464 pub pending_account_test: Option<mxr_protocol::AccountConfigData>,
465 pub pending_account_authorize: Option<(mxr_protocol::AccountConfigData, bool)>,
466 pub pending_account_set_default: Option<String>,
467 pub sidebar_selected: usize,
468 pub sidebar_section: SidebarSection,
469 pub help_modal_open: bool,
470 pub help_scroll_offset: u16,
471 pub saved_searches: Vec<mxr_core::SavedSearch>,
472 pub subscriptions_page: SubscriptionsPageState,
473 pub rules_page: RulesPageState,
474 pub diagnostics_page: DiagnosticsPageState,
475 pub accounts_page: AccountsPageState,
476 pub active_label: Option<mxr_core::LabelId>,
477 pub pending_label_fetch: Option<mxr_core::LabelId>,
478 pub pending_active_label: Option<mxr_core::LabelId>,
479 pub pending_labels_refresh: bool,
480 pub pending_all_envelopes_refresh: bool,
481 pub pending_subscriptions_refresh: bool,
482 pub pending_status_refresh: bool,
483 pub desired_system_mailbox: Option<String>,
484 pub status_message: Option<String>,
485 pending_preview_read: Option<PendingPreviewRead>,
486 pub pending_mutation_count: usize,
487 pub pending_mutation_status: Option<String>,
488 pub pending_mutation_queue: Vec<(Request, MutationEffect)>,
489 pub pending_compose: Option<ComposeAction>,
490 pub pending_send_confirm: Option<PendingSend>,
491 pub pending_bulk_confirm: Option<PendingBulkConfirm>,
492 pub error_modal: Option<ErrorModalState>,
493 pub pending_unsubscribe_confirm: Option<PendingUnsubscribeConfirm>,
494 pub pending_unsubscribe_action: Option<PendingUnsubscribeAction>,
495 pub reader_mode: bool,
496 pub signature_expanded: bool,
497 pub label_picker: LabelPicker,
498 pub compose_picker: ComposePicker,
499 pub attachment_panel: AttachmentPanelState,
500 pub snooze_panel: SnoozePanelState,
501 pub pending_attachment_action: Option<PendingAttachmentAction>,
502 pub selected_set: HashSet<MessageId>,
503 pub visual_mode: bool,
504 pub visual_anchor: Option<usize>,
505 pub pending_export_thread: Option<mxr_core::id::ThreadId>,
506 pub snooze_config: mxr_config::SnoozeConfig,
507 pub sidebar_system_expanded: bool,
508 pub sidebar_user_expanded: bool,
509 pub sidebar_saved_searches_expanded: bool,
510 pending_label_action: Option<(LabelPickerMode, String)>,
511 pub url_modal: Option<ui::url_modal::UrlModalState>,
512 input: InputHandler,
513}
514
515impl Default for App {
516 fn default() -> Self {
517 Self::new()
518 }
519}
520
521impl App {
522 pub fn new() -> Self {
523 Self::from_render_and_snooze(
524 &RenderConfig::default(),
525 &mxr_config::SnoozeConfig::default(),
526 )
527 }
528
529 pub fn from_config(config: &mxr_config::MxrConfig) -> Self {
530 let mut app = Self::from_render_and_snooze(&config.render, &config.snooze);
531 app.theme = Theme::from_spec(&config.appearance.theme);
532 if config.accounts.is_empty() {
533 app.enter_account_setup_onboarding();
534 }
535 app
536 }
537
538 pub fn from_render_config(render: &RenderConfig) -> Self {
539 Self::from_render_and_snooze(render, &mxr_config::SnoozeConfig::default())
540 }
541
542 fn from_render_and_snooze(
543 render: &RenderConfig,
544 snooze_config: &mxr_config::SnoozeConfig,
545 ) -> Self {
546 Self {
547 theme: Theme::default(),
548 envelopes: Vec::new(),
549 all_envelopes: Vec::new(),
550 mailbox_view: MailboxView::Messages,
551 labels: Vec::new(),
552 screen: Screen::Mailbox,
553 mail_list_mode: MailListMode::Threads,
554 selected_index: 0,
555 scroll_offset: 0,
556 active_pane: ActivePane::MailList,
557 should_quit: false,
558 layout_mode: LayoutMode::TwoPane,
559 search_bar: SearchBar::default(),
560 search_page: SearchPageState::default(),
561 command_palette: CommandPalette::default(),
562 body_view_state: BodyViewState::Empty { preview: None },
563 viewing_envelope: None,
564 viewed_thread: None,
565 viewed_thread_messages: Vec::new(),
566 thread_selected_index: 0,
567 message_scroll_offset: 0,
568 last_sync_status: None,
569 visible_height: 20,
570 body_cache: HashMap::new(),
571 queued_body_fetches: Vec::new(),
572 in_flight_body_requests: HashSet::new(),
573 pending_thread_fetch: None,
574 in_flight_thread_fetch: None,
575 pending_search: None,
576 search_active: false,
577 pending_rule_detail: None,
578 pending_rule_history: None,
579 pending_rule_dry_run: None,
580 pending_rule_delete: None,
581 pending_rule_upsert: None,
582 pending_rule_form_load: None,
583 pending_rule_form_save: false,
584 pending_bug_report: false,
585 pending_account_save: None,
586 pending_account_test: None,
587 pending_account_authorize: None,
588 pending_account_set_default: None,
589 sidebar_selected: 0,
590 sidebar_section: SidebarSection::Labels,
591 help_modal_open: false,
592 help_scroll_offset: 0,
593 saved_searches: Vec::new(),
594 subscriptions_page: SubscriptionsPageState::default(),
595 rules_page: RulesPageState::default(),
596 diagnostics_page: DiagnosticsPageState::default(),
597 accounts_page: AccountsPageState::default(),
598 active_label: None,
599 pending_label_fetch: None,
600 pending_active_label: None,
601 pending_labels_refresh: false,
602 pending_all_envelopes_refresh: false,
603 pending_subscriptions_refresh: false,
604 pending_status_refresh: false,
605 desired_system_mailbox: None,
606 status_message: None,
607 pending_preview_read: None,
608 pending_mutation_count: 0,
609 pending_mutation_status: None,
610 pending_mutation_queue: Vec::new(),
611 pending_compose: None,
612 pending_send_confirm: None,
613 pending_bulk_confirm: None,
614 error_modal: None,
615 pending_unsubscribe_confirm: None,
616 pending_unsubscribe_action: None,
617 reader_mode: render.reader_mode,
618 signature_expanded: false,
619 label_picker: LabelPicker::default(),
620 compose_picker: ComposePicker::default(),
621 attachment_panel: AttachmentPanelState::default(),
622 snooze_panel: SnoozePanelState::default(),
623 pending_attachment_action: None,
624 selected_set: HashSet::new(),
625 visual_mode: false,
626 visual_anchor: None,
627 pending_export_thread: None,
628 snooze_config: snooze_config.clone(),
629 sidebar_system_expanded: true,
630 sidebar_user_expanded: true,
631 sidebar_saved_searches_expanded: true,
632 pending_label_action: None,
633 url_modal: None,
634 input: InputHandler::new(),
635 }
636 }
637
638 pub fn selected_envelope(&self) -> Option<&Envelope> {
639 if self.mailbox_view == MailboxView::Subscriptions {
640 return self
641 .subscriptions_page
642 .entries
643 .get(self.selected_index)
644 .map(|entry| &entry.envelope);
645 }
646
647 match self.mail_list_mode {
648 MailListMode::Messages => self.envelopes.get(self.selected_index),
649 MailListMode::Threads => self.selected_mail_row().and_then(|row| {
650 self.envelopes
651 .iter()
652 .find(|env| env.id == row.representative.id)
653 }),
654 }
655 }
656
657 pub fn mail_list_rows(&self) -> Vec<MailListRow> {
658 Self::build_mail_list_rows(&self.envelopes, self.mail_list_mode)
659 }
660
661 pub fn search_mail_list_rows(&self) -> Vec<MailListRow> {
662 Self::build_mail_list_rows(&self.search_page.results, self.mail_list_mode)
663 }
664
665 pub fn selected_mail_row(&self) -> Option<MailListRow> {
666 if self.mailbox_view == MailboxView::Subscriptions {
667 return None;
668 }
669 self.mail_list_rows().get(self.selected_index).cloned()
670 }
671
672 pub fn selected_subscription_entry(&self) -> Option<&SubscriptionEntry> {
673 self.subscriptions_page.entries.get(self.selected_index)
674 }
675
676 pub fn focused_thread_envelope(&self) -> Option<&Envelope> {
677 self.viewed_thread_messages.get(self.thread_selected_index)
678 }
679
680 pub fn sidebar_items(&self) -> Vec<SidebarItem> {
681 let mut items = Vec::new();
682 let mut system_labels = Vec::new();
683 let mut user_labels = Vec::new();
684 for label in self.visible_labels() {
685 if label.kind == LabelKind::System {
686 system_labels.push(label.clone());
687 } else {
688 user_labels.push(label.clone());
689 }
690 }
691 if self.sidebar_system_expanded {
692 items.extend(system_labels.into_iter().map(SidebarItem::Label));
693 }
694 items.push(SidebarItem::AllMail);
695 items.push(SidebarItem::Subscriptions);
696 if self.sidebar_user_expanded {
697 items.extend(user_labels.into_iter().map(SidebarItem::Label));
698 }
699 if self.sidebar_saved_searches_expanded {
700 items.extend(
701 self.saved_searches
702 .iter()
703 .cloned()
704 .map(SidebarItem::SavedSearch),
705 );
706 }
707 items
708 }
709
710 pub fn selected_sidebar_item(&self) -> Option<SidebarItem> {
711 self.sidebar_items().get(self.sidebar_selected).cloned()
712 }
713
714 pub fn selected_search_envelope(&self) -> Option<&Envelope> {
715 match self.mail_list_mode {
716 MailListMode::Messages => self
717 .search_page
718 .results
719 .get(self.search_page.selected_index),
720 MailListMode::Threads => self
721 .search_mail_list_rows()
722 .get(self.search_page.selected_index)
723 .and_then(|row| {
724 self.search_page
725 .results
726 .iter()
727 .find(|env| env.id == row.representative.id)
728 }),
729 }
730 }
731
732 pub fn selected_rule(&self) -> Option<&serde_json::Value> {
733 self.rules_page.rules.get(self.rules_page.selected_index)
734 }
735
736 pub fn selected_account(&self) -> Option<&mxr_protocol::AccountSummaryData> {
737 self.accounts_page
738 .accounts
739 .get(self.accounts_page.selected_index)
740 }
741
742 pub fn enter_account_setup_onboarding(&mut self) {
743 self.screen = Screen::Accounts;
744 self.accounts_page.refresh_pending = true;
745 self.accounts_page.onboarding_required = true;
746 self.accounts_page.onboarding_modal_open = true;
747 self.active_label = None;
748 self.pending_active_label = None;
749 self.pending_label_fetch = None;
750 self.desired_system_mailbox = None;
751 }
752
753 fn complete_account_setup_onboarding(&mut self) {
754 self.accounts_page.onboarding_modal_open = false;
755 self.apply(Action::OpenAccountFormNew);
756 }
757
758 fn selected_account_config(&self) -> Option<mxr_protocol::AccountConfigData> {
759 self.selected_account().and_then(account_summary_to_config)
760 }
761
762 fn account_form_field_count(&self) -> usize {
763 match self.accounts_page.form.mode {
764 AccountFormMode::Gmail => {
765 if self.accounts_page.form.gmail_credential_source
766 == mxr_protocol::GmailCredentialSourceData::Custom
767 {
768 8
769 } else {
770 6
771 }
772 }
773 AccountFormMode::ImapSmtp => 14,
774 AccountFormMode::SmtpOnly => 9,
775 }
776 }
777
778 fn account_form_data(&self, is_default: bool) -> mxr_protocol::AccountConfigData {
779 let form = &self.accounts_page.form;
780 let key = form.key.trim().to_string();
781 let name = if form.name.trim().is_empty() {
782 key.clone()
783 } else {
784 form.name.trim().to_string()
785 };
786 let email = form.email.trim().to_string();
787 let imap_username = if form.imap_username.trim().is_empty() {
788 email.clone()
789 } else {
790 form.imap_username.trim().to_string()
791 };
792 let smtp_username = if form.smtp_username.trim().is_empty() {
793 email.clone()
794 } else {
795 form.smtp_username.trim().to_string()
796 };
797 let gmail_token_ref = if form.gmail_token_ref.trim().is_empty() {
798 format!("mxr/{key}-gmail")
799 } else {
800 form.gmail_token_ref.trim().to_string()
801 };
802 let sync = match form.mode {
803 AccountFormMode::Gmail => Some(mxr_protocol::AccountSyncConfigData::Gmail {
804 credential_source: form.gmail_credential_source.clone(),
805 client_id: form.gmail_client_id.trim().to_string(),
806 client_secret: if form.gmail_client_secret.trim().is_empty() {
807 None
808 } else {
809 Some(form.gmail_client_secret.clone())
810 },
811 token_ref: gmail_token_ref,
812 }),
813 AccountFormMode::ImapSmtp => Some(mxr_protocol::AccountSyncConfigData::Imap {
814 host: form.imap_host.trim().to_string(),
815 port: form.imap_port.parse().unwrap_or(993),
816 username: imap_username,
817 password_ref: form.imap_password_ref.trim().to_string(),
818 password: if form.imap_password.is_empty() {
819 None
820 } else {
821 Some(form.imap_password.clone())
822 },
823 use_tls: true,
824 }),
825 AccountFormMode::SmtpOnly => None,
826 };
827 let send = match form.mode {
828 AccountFormMode::Gmail => Some(mxr_protocol::AccountSendConfigData::Gmail),
829 AccountFormMode::ImapSmtp | AccountFormMode::SmtpOnly => {
830 Some(mxr_protocol::AccountSendConfigData::Smtp {
831 host: form.smtp_host.trim().to_string(),
832 port: form.smtp_port.parse().unwrap_or(587),
833 username: smtp_username,
834 password_ref: form.smtp_password_ref.trim().to_string(),
835 password: if form.smtp_password.is_empty() {
836 None
837 } else {
838 Some(form.smtp_password.clone())
839 },
840 use_tls: true,
841 })
842 }
843 };
844 mxr_protocol::AccountConfigData {
845 key,
846 name,
847 email,
848 sync,
849 send,
850 is_default,
851 }
852 }
853
854 fn next_account_form_mode(&self, forward: bool) -> AccountFormMode {
855 match (self.accounts_page.form.mode, forward) {
856 (AccountFormMode::Gmail, true) => AccountFormMode::ImapSmtp,
857 (AccountFormMode::ImapSmtp, true) => AccountFormMode::SmtpOnly,
858 (AccountFormMode::SmtpOnly, true) => AccountFormMode::Gmail,
859 (AccountFormMode::Gmail, false) => AccountFormMode::SmtpOnly,
860 (AccountFormMode::ImapSmtp, false) => AccountFormMode::Gmail,
861 (AccountFormMode::SmtpOnly, false) => AccountFormMode::ImapSmtp,
862 }
863 }
864
865 fn account_form_has_meaningful_input(&self) -> bool {
866 let form = &self.accounts_page.form;
867 [
868 form.key.trim(),
869 form.name.trim(),
870 form.email.trim(),
871 form.gmail_client_id.trim(),
872 form.gmail_client_secret.trim(),
873 form.imap_host.trim(),
874 form.imap_username.trim(),
875 form.imap_password_ref.trim(),
876 form.imap_password.trim(),
877 form.smtp_host.trim(),
878 form.smtp_username.trim(),
879 form.smtp_password_ref.trim(),
880 form.smtp_password.trim(),
881 ]
882 .iter()
883 .any(|value| !value.is_empty())
884 }
885
886 fn apply_account_form_mode(&mut self, mode: AccountFormMode) {
887 self.accounts_page.form.mode = mode;
888 self.accounts_page.form.pending_mode_switch = None;
889 self.accounts_page.form.active_field = self
890 .accounts_page
891 .form
892 .active_field
893 .min(self.account_form_field_count().saturating_sub(1));
894 self.accounts_page.form.editing_field = false;
895 self.accounts_page.form.field_cursor = 0;
896 self.refresh_account_form_derived_fields();
897 }
898
899 fn request_account_form_mode_change(&mut self, forward: bool) {
900 let next_mode = self.next_account_form_mode(forward);
901 if next_mode == self.accounts_page.form.mode {
902 return;
903 }
904 if self.account_form_has_meaningful_input() {
905 self.accounts_page.form.pending_mode_switch = Some(next_mode);
906 } else {
907 self.apply_account_form_mode(next_mode);
908 }
909 }
910
911 fn refresh_account_form_derived_fields(&mut self) {
912 if matches!(self.accounts_page.form.mode, AccountFormMode::Gmail) {
913 let key = self.accounts_page.form.key.trim();
914 let token_ref = if key.is_empty() {
915 String::new()
916 } else {
917 format!("mxr/{key}-gmail")
918 };
919 self.accounts_page.form.gmail_token_ref = token_ref;
920 }
921 }
922
923 fn mail_row_count(&self) -> usize {
924 if self.mailbox_view == MailboxView::Subscriptions {
925 return self.subscriptions_page.entries.len();
926 }
927 self.mail_list_rows().len()
928 }
929
930 fn search_row_count(&self) -> usize {
931 self.search_mail_list_rows().len()
932 }
933
934 fn build_mail_list_rows(envelopes: &[Envelope], mode: MailListMode) -> Vec<MailListRow> {
935 match mode {
936 MailListMode::Messages => envelopes
937 .iter()
938 .map(|envelope| MailListRow {
939 thread_id: envelope.thread_id.clone(),
940 representative: envelope.clone(),
941 message_count: 1,
942 unread_count: usize::from(!envelope.flags.contains(MessageFlags::READ)),
943 })
944 .collect(),
945 MailListMode::Threads => {
946 let mut order: Vec<mxr_core::ThreadId> = Vec::new();
947 let mut rows: HashMap<mxr_core::ThreadId, MailListRow> = HashMap::new();
948 for envelope in envelopes {
949 let entry = rows.entry(envelope.thread_id.clone()).or_insert_with(|| {
950 order.push(envelope.thread_id.clone());
951 MailListRow {
952 thread_id: envelope.thread_id.clone(),
953 representative: envelope.clone(),
954 message_count: 0,
955 unread_count: 0,
956 }
957 });
958 entry.message_count += 1;
959 if !envelope.flags.contains(MessageFlags::READ) {
960 entry.unread_count += 1;
961 }
962 if sane_mail_sort_timestamp(&envelope.date)
963 > sane_mail_sort_timestamp(&entry.representative.date)
964 {
965 entry.representative = envelope.clone();
966 }
967 }
968 order
969 .into_iter()
970 .filter_map(|thread_id| rows.remove(&thread_id))
971 .collect()
972 }
973 }
974 }
975
976 fn context_envelope(&self) -> Option<&Envelope> {
978 if self.screen == Screen::Search {
979 return self
980 .focused_thread_envelope()
981 .or(self.viewing_envelope.as_ref())
982 .or_else(|| self.selected_search_envelope());
983 }
984
985 self.focused_thread_envelope()
986 .or(self.viewing_envelope.as_ref())
987 .or_else(|| self.selected_envelope())
988 }
989
990 pub async fn load(&mut self, client: &mut Client) -> Result<(), MxrError> {
991 self.labels = client.list_labels().await?;
992 self.all_envelopes = client.list_envelopes(5000, 0).await?;
993 self.load_initial_mailbox(client).await?;
994 self.saved_searches = client.list_saved_searches().await.unwrap_or_default();
995 self.set_subscriptions(client.list_subscriptions(500).await.unwrap_or_default());
996 if let Ok(Response::Ok {
997 data:
998 ResponseData::Status {
999 uptime_secs,
1000 daemon_pid,
1001 accounts,
1002 total_messages,
1003 sync_statuses,
1004 ..
1005 },
1006 }) = client.raw_request(Request::GetStatus).await
1007 {
1008 self.apply_status_snapshot(
1009 uptime_secs,
1010 daemon_pid,
1011 accounts,
1012 total_messages,
1013 sync_statuses,
1014 );
1015 }
1016 self.queue_body_window();
1018 Ok(())
1019 }
1020
1021 async fn load_initial_mailbox(&mut self, client: &mut Client) -> Result<(), MxrError> {
1022 let Some(inbox_id) = self
1023 .labels
1024 .iter()
1025 .find(|label| label.name == "INBOX")
1026 .map(|label| label.id.clone())
1027 else {
1028 self.envelopes = self.all_mail_envelopes();
1029 self.active_label = None;
1030 return Ok(());
1031 };
1032
1033 match client
1034 .raw_request(Request::ListEnvelopes {
1035 label_id: Some(inbox_id.clone()),
1036 account_id: None,
1037 limit: 5000,
1038 offset: 0,
1039 })
1040 .await
1041 {
1042 Ok(Response::Ok {
1043 data: ResponseData::Envelopes { envelopes },
1044 }) => {
1045 self.envelopes = envelopes;
1046 self.active_label = Some(inbox_id);
1047 self.pending_active_label = None;
1048 self.pending_label_fetch = None;
1049 self.sidebar_selected = 0;
1050 Ok(())
1051 }
1052 Ok(Response::Error { message }) => {
1053 self.envelopes = self.all_mail_envelopes();
1054 self.active_label = None;
1055 self.status_message = Some(format!("Inbox load failed: {message}"));
1056 Ok(())
1057 }
1058 Ok(_) => {
1059 self.envelopes = self.all_mail_envelopes();
1060 self.active_label = None;
1061 self.status_message = Some("Inbox load failed: unexpected response".into());
1062 Ok(())
1063 }
1064 Err(error) => {
1065 self.envelopes = self.all_mail_envelopes();
1066 self.active_label = None;
1067 self.status_message = Some(format!("Inbox load failed: {error}"));
1068 Ok(())
1069 }
1070 }
1071 }
1072
1073 pub fn apply_status_snapshot(
1074 &mut self,
1075 uptime_secs: u64,
1076 daemon_pid: Option<u32>,
1077 accounts: Vec<String>,
1078 total_messages: u32,
1079 sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
1080 ) {
1081 self.diagnostics_page.uptime_secs = Some(uptime_secs);
1082 self.diagnostics_page.daemon_pid = daemon_pid;
1083 self.diagnostics_page.accounts = accounts;
1084 self.diagnostics_page.total_messages = Some(total_messages);
1085 self.diagnostics_page.sync_statuses = sync_statuses;
1086 self.last_sync_status = Some(Self::summarize_sync_status(
1087 &self.diagnostics_page.sync_statuses,
1088 ));
1089 }
1090
1091 pub fn input_pending(&self) -> bool {
1092 self.input.is_pending()
1093 }
1094
1095 pub fn ordered_visible_labels(&self) -> Vec<&Label> {
1096 let mut system: Vec<&Label> = self
1097 .labels
1098 .iter()
1099 .filter(|l| !crate::ui::sidebar::should_hide_label(&l.name))
1100 .filter(|l| l.kind == mxr_core::types::LabelKind::System)
1101 .filter(|l| {
1102 crate::ui::sidebar::is_primary_system_label(&l.name)
1103 || l.total_count > 0
1104 || l.unread_count > 0
1105 })
1106 .collect();
1107 system.sort_by_key(|l| crate::ui::sidebar::system_label_order(&l.name));
1108
1109 let mut user: Vec<&Label> = self
1110 .labels
1111 .iter()
1112 .filter(|l| !crate::ui::sidebar::should_hide_label(&l.name))
1113 .filter(|l| l.kind != mxr_core::types::LabelKind::System)
1114 .collect();
1115 user.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1116
1117 let mut result = system;
1118 result.extend(user);
1119 result
1120 }
1121
1122 pub fn visible_label_count(&self) -> usize {
1124 self.ordered_visible_labels().len()
1125 }
1126
1127 pub fn visible_labels(&self) -> Vec<&Label> {
1129 self.ordered_visible_labels()
1130 }
1131
1132 fn sidebar_move_down(&mut self) {
1133 if self.sidebar_selected + 1 < self.sidebar_items().len() {
1134 self.sidebar_selected += 1;
1135 }
1136 self.sync_sidebar_section();
1137 }
1138
1139 fn sidebar_move_up(&mut self) {
1140 self.sidebar_selected = self.sidebar_selected.saturating_sub(1);
1141 self.sync_sidebar_section();
1142 }
1143
1144 fn sidebar_select(&mut self) -> Option<Action> {
1145 match self.selected_sidebar_item() {
1146 Some(SidebarItem::AllMail) => Some(Action::GoToAllMail),
1147 Some(SidebarItem::Subscriptions) => Some(Action::OpenSubscriptions),
1148 Some(SidebarItem::Label(label)) => Some(Action::SelectLabel(label.id)),
1149 Some(SidebarItem::SavedSearch(search)) => {
1150 Some(Action::SelectSavedSearch(search.query, search.search_mode))
1151 }
1152 None => None,
1153 }
1154 }
1155
1156 fn sync_sidebar_section(&mut self) {
1157 self.sidebar_section = match self.selected_sidebar_item() {
1158 Some(SidebarItem::SavedSearch(_)) => SidebarSection::SavedSearches,
1159 _ => SidebarSection::Labels,
1160 };
1161 }
1162
1163 fn current_sidebar_group(&self) -> SidebarGroup {
1164 match self.selected_sidebar_item() {
1165 Some(SidebarItem::SavedSearch(_)) => SidebarGroup::SavedSearches,
1166 Some(SidebarItem::Label(label)) if label.kind == LabelKind::System => {
1167 SidebarGroup::SystemLabels
1168 }
1169 Some(SidebarItem::Label(_)) => SidebarGroup::UserLabels,
1170 Some(SidebarItem::AllMail) | Some(SidebarItem::Subscriptions) | None => {
1171 SidebarGroup::SystemLabels
1172 }
1173 }
1174 }
1175
1176 fn collapse_current_sidebar_section(&mut self) {
1177 match self.current_sidebar_group() {
1178 SidebarGroup::SystemLabels => self.sidebar_system_expanded = false,
1179 SidebarGroup::UserLabels => self.sidebar_user_expanded = false,
1180 SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = false,
1181 }
1182 self.sidebar_selected = self
1183 .sidebar_selected
1184 .min(self.sidebar_items().len().saturating_sub(1));
1185 self.sync_sidebar_section();
1186 }
1187
1188 fn expand_current_sidebar_section(&mut self) {
1189 match self.current_sidebar_group() {
1190 SidebarGroup::SystemLabels => self.sidebar_system_expanded = true,
1191 SidebarGroup::UserLabels => self.sidebar_user_expanded = true,
1192 SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = true,
1193 }
1194 self.sidebar_selected = self
1195 .sidebar_selected
1196 .min(self.sidebar_items().len().saturating_sub(1));
1197 self.sync_sidebar_section();
1198 }
1199
1200 fn trigger_live_search(&mut self) {
1203 let query = self.search_bar.query.to_lowercase();
1204 if query.is_empty() {
1205 self.envelopes = self.all_mail_envelopes();
1206 self.search_active = false;
1207 } else {
1208 let query_words: Vec<&str> = query.split_whitespace().collect();
1209 self.envelopes = self
1212 .all_envelopes
1213 .iter()
1214 .filter(|e| !e.flags.contains(MessageFlags::TRASH))
1215 .filter(|e| {
1216 let haystack = format!(
1217 "{} {} {} {}",
1218 e.subject,
1219 e.from.email,
1220 e.from.name.as_deref().unwrap_or(""),
1221 e.snippet
1222 )
1223 .to_lowercase();
1224 query_words.iter().all(|qw| {
1225 haystack.split_whitespace().any(|hw| hw.starts_with(qw))
1226 || haystack.contains(qw)
1227 })
1228 })
1229 .cloned()
1230 .collect();
1231 self.search_active = true;
1232 self.pending_search = Some((self.search_bar.query.clone(), self.search_bar.mode));
1234 }
1235 self.selected_index = 0;
1236 self.scroll_offset = 0;
1237 }
1238
1239 pub fn mail_list_title(&self) -> String {
1241 if self.mailbox_view == MailboxView::Subscriptions {
1242 return format!("Subscriptions ({})", self.subscriptions_page.entries.len());
1243 }
1244
1245 let list_name = match self.mail_list_mode {
1246 MailListMode::Threads => "Threads",
1247 MailListMode::Messages => "Messages",
1248 };
1249 let list_count = self.mail_row_count();
1250 if self.search_active {
1251 format!("Search: {} ({list_count})", self.search_bar.query)
1252 } else if let Some(label_id) = self
1253 .pending_active_label
1254 .as_ref()
1255 .or(self.active_label.as_ref())
1256 {
1257 if let Some(label) = self.labels.iter().find(|l| &l.id == label_id) {
1258 let name = crate::ui::sidebar::humanize_label(&label.name);
1259 format!("{name} {list_name} ({list_count})")
1260 } else {
1261 format!("{list_name} ({list_count})")
1262 }
1263 } else {
1264 format!("All Mail {list_name} ({list_count})")
1265 }
1266 }
1267
1268 fn all_mail_envelopes(&self) -> Vec<Envelope> {
1269 self.all_envelopes
1270 .iter()
1271 .filter(|envelope| !envelope.flags.contains(MessageFlags::TRASH))
1272 .cloned()
1273 .collect()
1274 }
1275
1276 fn active_label_record(&self) -> Option<&Label> {
1277 let label_id = self
1278 .pending_active_label
1279 .as_ref()
1280 .or(self.active_label.as_ref())?;
1281 self.labels.iter().find(|label| &label.id == label_id)
1282 }
1283
1284 fn global_starred_count(&self) -> usize {
1285 self.labels
1286 .iter()
1287 .find(|label| label.name.eq_ignore_ascii_case("STARRED"))
1288 .map(|label| label.total_count as usize)
1289 .unwrap_or_else(|| {
1290 self.all_envelopes
1291 .iter()
1292 .filter(|envelope| envelope.flags.contains(MessageFlags::STARRED))
1293 .count()
1294 })
1295 }
1296
1297 pub fn status_bar_state(&self) -> ui::status_bar::StatusBarState {
1298 let starred_count = self.global_starred_count();
1299
1300 if self.mailbox_view == MailboxView::Subscriptions {
1301 let unread_count = self
1302 .subscriptions_page
1303 .entries
1304 .iter()
1305 .filter(|entry| !entry.envelope.flags.contains(MessageFlags::READ))
1306 .count();
1307 return ui::status_bar::StatusBarState {
1308 mailbox_name: "SUBSCRIPTIONS".into(),
1309 total_count: self.subscriptions_page.entries.len(),
1310 unread_count,
1311 starred_count,
1312 sync_status: self.last_sync_status.clone(),
1313 status_message: self.status_message.clone(),
1314 pending_mutation_count: self.pending_mutation_count,
1315 pending_mutation_status: self.pending_mutation_status.clone(),
1316 };
1317 }
1318
1319 if self.screen == Screen::Search || self.search_active {
1320 let results = if self.screen == Screen::Search {
1321 &self.search_page.results
1322 } else {
1323 &self.envelopes
1324 };
1325 let unread_count = results
1326 .iter()
1327 .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1328 .count();
1329 return ui::status_bar::StatusBarState {
1330 mailbox_name: "SEARCH".into(),
1331 total_count: results.len(),
1332 unread_count,
1333 starred_count,
1334 sync_status: self.last_sync_status.clone(),
1335 status_message: self.status_message.clone(),
1336 pending_mutation_count: self.pending_mutation_count,
1337 pending_mutation_status: self.pending_mutation_status.clone(),
1338 };
1339 }
1340
1341 if let Some(label) = self.active_label_record() {
1342 return ui::status_bar::StatusBarState {
1343 mailbox_name: label.name.clone(),
1344 total_count: label.total_count as usize,
1345 unread_count: label.unread_count as usize,
1346 starred_count,
1347 sync_status: self.last_sync_status.clone(),
1348 status_message: self.status_message.clone(),
1349 pending_mutation_count: self.pending_mutation_count,
1350 pending_mutation_status: self.pending_mutation_status.clone(),
1351 };
1352 }
1353
1354 let unread_count = self
1355 .envelopes
1356 .iter()
1357 .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1358 .count();
1359 ui::status_bar::StatusBarState {
1360 mailbox_name: "ALL MAIL".into(),
1361 total_count: self
1362 .diagnostics_page
1363 .total_messages
1364 .map(|count| count as usize)
1365 .unwrap_or_else(|| self.all_envelopes.len()),
1366 unread_count,
1367 starred_count,
1368 sync_status: self.last_sync_status.clone(),
1369 status_message: self.status_message.clone(),
1370 pending_mutation_count: self.pending_mutation_count,
1371 pending_mutation_status: self.pending_mutation_status.clone(),
1372 }
1373 }
1374
1375 fn summarize_sync_status(sync_statuses: &[mxr_protocol::AccountSyncStatus]) -> String {
1376 if sync_statuses.is_empty() {
1377 return "not synced".into();
1378 }
1379 if sync_statuses.iter().any(|sync| sync.sync_in_progress) {
1380 return "syncing".into();
1381 }
1382 if sync_statuses
1383 .iter()
1384 .any(|sync| !sync.healthy || sync.last_error.is_some())
1385 {
1386 return "degraded".into();
1387 }
1388 sync_statuses
1389 .iter()
1390 .filter_map(|sync| sync.last_success_at.as_deref())
1391 .filter_map(Self::format_sync_age)
1392 .max_by_key(|(_, sort_key)| *sort_key)
1393 .map(|(display, _)| format!("synced {display}"))
1394 .unwrap_or_else(|| "not synced".into())
1395 }
1396
1397 fn format_sync_age(timestamp: &str) -> Option<(String, i64)> {
1398 let parsed = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?;
1399 let synced_at = parsed.with_timezone(&chrono::Utc);
1400 let elapsed = chrono::Utc::now().signed_duration_since(synced_at);
1401 let seconds = elapsed.num_seconds().max(0);
1402 let display = if seconds < 60 {
1403 "just now".to_string()
1404 } else if seconds < 3_600 {
1405 format!("{}m ago", seconds / 60)
1406 } else if seconds < 86_400 {
1407 format!("{}h ago", seconds / 3_600)
1408 } else {
1409 format!("{}d ago", seconds / 86_400)
1410 };
1411 Some((display, synced_at.timestamp()))
1412 }
1413
1414 pub fn resolve_desired_system_mailbox(&mut self) {
1415 let Some(target) = self.desired_system_mailbox.as_deref() else {
1416 return;
1417 };
1418 if self.pending_active_label.is_some() || self.active_label.is_some() {
1419 return;
1420 }
1421 if let Some(label_id) = self
1422 .labels
1423 .iter()
1424 .find(|label| label.name.eq_ignore_ascii_case(target))
1425 .map(|label| label.id.clone())
1426 {
1427 self.apply(Action::SelectLabel(label_id));
1428 }
1429 }
1430
1431 fn auto_preview(&mut self) {
1433 if self.mailbox_view == MailboxView::Subscriptions {
1434 if let Some(entry) = self.selected_subscription_entry().cloned() {
1435 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&entry.envelope.id) {
1436 self.open_envelope(entry.envelope);
1437 }
1438 } else {
1439 self.pending_preview_read = None;
1440 self.viewing_envelope = None;
1441 self.viewed_thread = None;
1442 self.viewed_thread_messages.clear();
1443 self.body_view_state = BodyViewState::Empty { preview: None };
1444 }
1445 return;
1446 }
1447
1448 if self.layout_mode == LayoutMode::ThreePane {
1449 if let Some(row) = self.selected_mail_row() {
1450 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&row.representative.id) {
1451 self.open_envelope(row.representative);
1452 }
1453 }
1454 }
1455 }
1456
1457 pub fn auto_preview_search(&mut self) {
1458 if let Some(env) = self.selected_search_envelope().cloned() {
1459 if self
1460 .viewing_envelope
1461 .as_ref()
1462 .map(|current| current.id.clone())
1463 != Some(env.id.clone())
1464 {
1465 self.open_envelope(env);
1466 }
1467 }
1468 }
1469
1470 fn ensure_search_visible(&mut self) {
1471 let h = self.visible_height.max(1);
1472 if self.search_page.selected_index < self.search_page.scroll_offset {
1473 self.search_page.scroll_offset = self.search_page.selected_index;
1474 } else if self.search_page.selected_index >= self.search_page.scroll_offset + h {
1475 self.search_page.scroll_offset = self.search_page.selected_index + 1 - h;
1476 }
1477 }
1478
1479 pub fn queue_body_window(&mut self) {
1482 const BUFFER: usize = 50;
1483 let source_envelopes: Vec<Envelope> = if self.mailbox_view == MailboxView::Subscriptions {
1484 self.subscriptions_page
1485 .entries
1486 .iter()
1487 .map(|entry| entry.envelope.clone())
1488 .collect()
1489 } else {
1490 self.envelopes.clone()
1491 };
1492 let len = source_envelopes.len();
1493 if len == 0 {
1494 return;
1495 }
1496 let start = self.selected_index.saturating_sub(BUFFER / 2);
1497 let end = (self.selected_index + BUFFER / 2).min(len);
1498 let ids: Vec<MessageId> = source_envelopes[start..end]
1499 .iter()
1500 .map(|e| e.id.clone())
1501 .collect();
1502 for id in ids {
1503 self.queue_body_fetch(id);
1504 }
1505 }
1506
1507 fn open_envelope(&mut self, env: Envelope) {
1508 self.close_attachment_panel();
1509 self.signature_expanded = false;
1510 self.viewed_thread = None;
1511 self.viewed_thread_messages = self.optimistic_thread_messages(&env);
1512 self.thread_selected_index = self.default_thread_selected_index();
1513 self.viewing_envelope = self.focused_thread_envelope().cloned();
1514 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1515 self.schedule_preview_read(&viewing_envelope);
1516 }
1517 for message in self.viewed_thread_messages.clone() {
1518 self.queue_body_fetch(message.id);
1519 }
1520 self.queue_thread_fetch(env.thread_id.clone());
1521 self.message_scroll_offset = 0;
1522 self.ensure_current_body_state();
1523 }
1524
1525 fn optimistic_thread_messages(&self, env: &Envelope) -> Vec<Envelope> {
1526 let mut messages: Vec<Envelope> = self
1527 .all_envelopes
1528 .iter()
1529 .filter(|candidate| candidate.thread_id == env.thread_id)
1530 .cloned()
1531 .collect();
1532 if messages.is_empty() {
1533 messages.push(env.clone());
1534 }
1535 messages.sort_by_key(|message| message.date);
1536 messages
1537 }
1538
1539 fn default_thread_selected_index(&self) -> usize {
1540 self.viewed_thread_messages
1541 .iter()
1542 .rposition(|message| !message.flags.contains(MessageFlags::READ))
1543 .or_else(|| self.viewed_thread_messages.len().checked_sub(1))
1544 .unwrap_or(0)
1545 }
1546
1547 fn sync_focused_thread_envelope(&mut self) {
1548 self.close_attachment_panel();
1549 self.viewing_envelope = self.focused_thread_envelope().cloned();
1550 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1551 self.schedule_preview_read(&viewing_envelope);
1552 } else {
1553 self.pending_preview_read = None;
1554 }
1555 self.message_scroll_offset = 0;
1556 self.ensure_current_body_state();
1557 }
1558
1559 fn schedule_preview_read(&mut self, envelope: &Envelope) {
1560 if envelope.flags.contains(MessageFlags::READ)
1561 || self.has_pending_set_read(&envelope.id, true)
1562 {
1563 self.pending_preview_read = None;
1564 return;
1565 }
1566
1567 if self
1568 .pending_preview_read
1569 .as_ref()
1570 .is_some_and(|pending| pending.message_id == envelope.id)
1571 {
1572 return;
1573 }
1574
1575 self.pending_preview_read = Some(PendingPreviewRead {
1576 message_id: envelope.id.clone(),
1577 due_at: Instant::now() + PREVIEW_MARK_READ_DELAY,
1578 });
1579 }
1580
1581 fn has_pending_set_read(&self, message_id: &MessageId, read: bool) -> bool {
1582 self.pending_mutation_queue.iter().any(|(request, _)| {
1583 matches!(
1584 request,
1585 Request::Mutation(MutationCommand::SetRead { message_ids, read: queued_read })
1586 if *queued_read == read
1587 && message_ids.len() == 1
1588 && message_ids[0] == *message_id
1589 )
1590 })
1591 }
1592
1593 fn process_pending_preview_read(&mut self) {
1594 let Some(pending) = self.pending_preview_read.clone() else {
1595 return;
1596 };
1597 if Instant::now() < pending.due_at {
1598 return;
1599 }
1600 self.pending_preview_read = None;
1601
1602 let Some(envelope) = self
1603 .viewing_envelope
1604 .clone()
1605 .filter(|envelope| envelope.id == pending.message_id)
1606 else {
1607 return;
1608 };
1609
1610 if envelope.flags.contains(MessageFlags::READ)
1611 || self.has_pending_set_read(&envelope.id, true)
1612 {
1613 return;
1614 }
1615
1616 let mut flags = envelope.flags;
1617 flags.insert(MessageFlags::READ);
1618 self.apply_local_flags(&envelope.id, flags);
1619 self.queue_mutation(
1620 Request::Mutation(MutationCommand::SetRead {
1621 message_ids: vec![envelope.id.clone()],
1622 read: true,
1623 }),
1624 MutationEffect::StatusOnly("Marked message as read".into()),
1625 "Marking message as read...".into(),
1626 );
1627 }
1628
1629 pub fn next_background_timeout(&self, fallback: Duration) -> Duration {
1630 let Some(pending) = self.pending_preview_read.as_ref() else {
1631 return fallback;
1632 };
1633 fallback.min(
1634 pending
1635 .due_at
1636 .checked_duration_since(Instant::now())
1637 .unwrap_or(Duration::ZERO),
1638 )
1639 }
1640
1641 #[cfg(test)]
1642 pub fn expire_pending_preview_read_for_tests(&mut self) {
1643 if let Some(pending) = self.pending_preview_read.as_mut() {
1644 pending.due_at = Instant::now();
1645 }
1646 }
1647
1648 fn move_thread_focus_down(&mut self) {
1649 if self.thread_selected_index + 1 < self.viewed_thread_messages.len() {
1650 self.thread_selected_index += 1;
1651 self.sync_focused_thread_envelope();
1652 }
1653 }
1654
1655 fn move_thread_focus_up(&mut self) {
1656 if self.thread_selected_index > 0 {
1657 self.thread_selected_index -= 1;
1658 self.sync_focused_thread_envelope();
1659 }
1660 }
1661
1662 fn ensure_current_body_state(&mut self) {
1663 if let Some(env) = self.viewing_envelope.clone() {
1664 if !self.body_cache.contains_key(&env.id) {
1665 self.queue_body_fetch(env.id.clone());
1666 }
1667 self.body_view_state = self.resolve_body_view_state(&env);
1668 } else {
1669 self.body_view_state = BodyViewState::Empty { preview: None };
1670 }
1671 }
1672
1673 fn queue_body_fetch(&mut self, message_id: MessageId) {
1674 if self.body_cache.contains_key(&message_id)
1675 || self.in_flight_body_requests.contains(&message_id)
1676 || self.queued_body_fetches.contains(&message_id)
1677 {
1678 return;
1679 }
1680
1681 self.in_flight_body_requests.insert(message_id.clone());
1682 self.queued_body_fetches.push(message_id);
1683 }
1684
1685 fn queue_thread_fetch(&mut self, thread_id: mxr_core::ThreadId) {
1686 if self.pending_thread_fetch.as_ref() == Some(&thread_id)
1687 || self.in_flight_thread_fetch.as_ref() == Some(&thread_id)
1688 {
1689 return;
1690 }
1691 self.pending_thread_fetch = Some(thread_id);
1692 }
1693
1694 fn envelope_preview(envelope: &Envelope) -> Option<String> {
1695 let snippet = envelope.snippet.trim();
1696 if snippet.is_empty() {
1697 None
1698 } else {
1699 Some(envelope.snippet.clone())
1700 }
1701 }
1702
1703 fn render_body(raw: &str, source: BodySource, reader_mode: bool) -> String {
1704 if !reader_mode {
1705 return raw.to_string();
1706 }
1707
1708 let config = mxr_reader::ReaderConfig::default();
1709 match source {
1710 BodySource::Plain => mxr_reader::clean(Some(raw), None, &config).content,
1711 BodySource::Html => mxr_reader::clean(None, Some(raw), &config).content,
1712 BodySource::Snippet => raw.to_string(),
1713 }
1714 }
1715
1716 fn resolve_body_view_state(&self, envelope: &Envelope) -> BodyViewState {
1717 let preview = Self::envelope_preview(envelope);
1718
1719 if let Some(body) = self.body_cache.get(&envelope.id) {
1720 if let Some(raw) = body.text_plain.clone() {
1721 let rendered = Self::render_body(&raw, BodySource::Plain, self.reader_mode);
1722 return BodyViewState::Ready {
1723 raw,
1724 rendered,
1725 source: BodySource::Plain,
1726 };
1727 }
1728
1729 if let Some(raw) = body.text_html.clone() {
1730 let rendered = Self::render_body(&raw, BodySource::Html, self.reader_mode);
1731 return BodyViewState::Ready {
1732 raw,
1733 rendered,
1734 source: BodySource::Html,
1735 };
1736 }
1737
1738 return BodyViewState::Empty { preview };
1739 }
1740
1741 if self.in_flight_body_requests.contains(&envelope.id) {
1742 BodyViewState::Loading { preview }
1743 } else {
1744 BodyViewState::Empty { preview }
1745 }
1746 }
1747
1748 pub fn resolve_body_success(&mut self, body: MessageBody) {
1749 let message_id = body.message_id.clone();
1750 self.in_flight_body_requests.remove(&message_id);
1751 self.body_cache.insert(message_id.clone(), body);
1752
1753 if self.viewing_envelope.as_ref().map(|env| env.id.clone()) == Some(message_id) {
1754 self.ensure_current_body_state();
1755 }
1756 }
1757
1758 pub fn resolve_body_fetch_error(&mut self, message_id: &MessageId, message: String) {
1759 self.in_flight_body_requests.remove(message_id);
1760
1761 if let Some(env) = self
1762 .viewing_envelope
1763 .as_ref()
1764 .filter(|env| &env.id == message_id)
1765 {
1766 self.body_view_state = BodyViewState::Error {
1767 message,
1768 preview: Self::envelope_preview(env),
1769 };
1770 }
1771 }
1772
1773 pub fn current_viewing_body(&self) -> Option<&MessageBody> {
1774 self.viewing_envelope
1775 .as_ref()
1776 .and_then(|env| self.body_cache.get(&env.id))
1777 }
1778
1779 pub fn selected_attachment(&self) -> Option<&AttachmentMeta> {
1780 self.attachment_panel
1781 .attachments
1782 .get(self.attachment_panel.selected_index)
1783 }
1784
1785 pub fn open_attachment_panel(&mut self) {
1786 let Some(message_id) = self.viewing_envelope.as_ref().map(|env| env.id.clone()) else {
1787 self.status_message = Some("No message selected".into());
1788 return;
1789 };
1790 let Some(attachments) = self
1791 .current_viewing_body()
1792 .map(|body| body.attachments.clone())
1793 else {
1794 self.status_message = Some("No message body loaded".into());
1795 return;
1796 };
1797 if attachments.is_empty() {
1798 self.status_message = Some("No attachments".into());
1799 return;
1800 }
1801
1802 self.attachment_panel.visible = true;
1803 self.attachment_panel.message_id = Some(message_id);
1804 self.attachment_panel.attachments = attachments;
1805 self.attachment_panel.selected_index = 0;
1806 self.attachment_panel.status = None;
1807 }
1808
1809 pub fn open_url_modal(&mut self) {
1810 let body = self.current_viewing_body();
1811 let Some(body) = body else {
1812 self.status_message = Some("No message body loaded".into());
1813 return;
1814 };
1815 let text_plain = body.text_plain.as_deref();
1816 let text_html = body.text_html.as_deref();
1817 let urls = ui::url_modal::extract_urls(text_plain, text_html);
1818 if urls.is_empty() {
1819 self.status_message = Some("No links found".into());
1820 return;
1821 }
1822 self.url_modal = Some(ui::url_modal::UrlModalState::new(urls));
1823 }
1824
1825 pub fn close_attachment_panel(&mut self) {
1826 self.attachment_panel = AttachmentPanelState::default();
1827 self.pending_attachment_action = None;
1828 }
1829
1830 pub fn queue_attachment_action(&mut self, operation: AttachmentOperation) {
1831 let Some(message_id) = self.attachment_panel.message_id.clone() else {
1832 return;
1833 };
1834 let Some(attachment) = self.selected_attachment().cloned() else {
1835 return;
1836 };
1837
1838 self.attachment_panel.status = Some(match operation {
1839 AttachmentOperation::Open => format!("Opening {}...", attachment.filename),
1840 AttachmentOperation::Download => format!("Downloading {}...", attachment.filename),
1841 });
1842 self.pending_attachment_action = Some(PendingAttachmentAction {
1843 message_id,
1844 attachment_id: attachment.id,
1845 operation,
1846 });
1847 }
1848
1849 pub fn resolve_attachment_file(&mut self, file: &mxr_protocol::AttachmentFile) {
1850 let path = std::path::PathBuf::from(&file.path);
1851 for attachment in &mut self.attachment_panel.attachments {
1852 if attachment.id == file.attachment_id {
1853 attachment.local_path = Some(path.clone());
1854 }
1855 }
1856 for body in self.body_cache.values_mut() {
1857 for attachment in &mut body.attachments {
1858 if attachment.id == file.attachment_id {
1859 attachment.local_path = Some(path.clone());
1860 }
1861 }
1862 }
1863 }
1864
1865 fn label_chips_for_envelope(&self, envelope: &Envelope) -> Vec<String> {
1866 envelope
1867 .label_provider_ids
1868 .iter()
1869 .filter_map(|provider_id| {
1870 self.labels
1871 .iter()
1872 .find(|label| &label.provider_id == provider_id)
1873 .map(|label| crate::ui::sidebar::humanize_label(&label.name).to_string())
1874 })
1875 .collect()
1876 }
1877
1878 fn attachment_summaries_for_envelope(&self, envelope: &Envelope) -> Vec<AttachmentSummary> {
1879 self.body_cache
1880 .get(&envelope.id)
1881 .map(|body| {
1882 body.attachments
1883 .iter()
1884 .map(|attachment| AttachmentSummary {
1885 filename: attachment.filename.clone(),
1886 size_bytes: attachment.size_bytes,
1887 })
1888 .collect()
1889 })
1890 .unwrap_or_default()
1891 }
1892
1893 fn thread_message_blocks(&self) -> Vec<ui::message_view::ThreadMessageBlock> {
1894 self.viewed_thread_messages
1895 .iter()
1896 .map(|message| ui::message_view::ThreadMessageBlock {
1897 envelope: message.clone(),
1898 body_state: self.resolve_body_view_state(message),
1899 labels: self.label_chips_for_envelope(message),
1900 attachments: self.attachment_summaries_for_envelope(message),
1901 selected: self.viewing_envelope.as_ref().map(|env| env.id.clone())
1902 == Some(message.id.clone()),
1903 has_unsubscribe: !matches!(message.unsubscribe, UnsubscribeMethod::None),
1904 signature_expanded: self.signature_expanded,
1905 })
1906 .collect()
1907 }
1908
1909 pub fn apply_local_label_refs(
1910 &mut self,
1911 message_ids: &[MessageId],
1912 add: &[String],
1913 remove: &[String],
1914 ) {
1915 let add_provider_ids = self.resolve_label_provider_ids(add);
1916 let remove_provider_ids = self.resolve_label_provider_ids(remove);
1917 for envelope in self
1918 .envelopes
1919 .iter_mut()
1920 .chain(self.all_envelopes.iter_mut())
1921 .chain(self.search_page.results.iter_mut())
1922 .chain(self.viewed_thread_messages.iter_mut())
1923 {
1924 if message_ids
1925 .iter()
1926 .any(|message_id| message_id == &envelope.id)
1927 {
1928 apply_provider_label_changes(
1929 &mut envelope.label_provider_ids,
1930 &add_provider_ids,
1931 &remove_provider_ids,
1932 );
1933 }
1934 }
1935 if let Some(ref mut envelope) = self.viewing_envelope {
1936 if message_ids
1937 .iter()
1938 .any(|message_id| message_id == &envelope.id)
1939 {
1940 apply_provider_label_changes(
1941 &mut envelope.label_provider_ids,
1942 &add_provider_ids,
1943 &remove_provider_ids,
1944 );
1945 }
1946 }
1947 }
1948
1949 pub fn apply_local_flags(&mut self, message_id: &MessageId, flags: MessageFlags) {
1950 for envelope in self
1951 .envelopes
1952 .iter_mut()
1953 .chain(self.all_envelopes.iter_mut())
1954 .chain(self.search_page.results.iter_mut())
1955 .chain(self.viewed_thread_messages.iter_mut())
1956 {
1957 if &envelope.id == message_id {
1958 envelope.flags = flags;
1959 }
1960 }
1961 if let Some(envelope) = self.viewing_envelope.as_mut() {
1962 if &envelope.id == message_id {
1963 envelope.flags = flags;
1964 }
1965 }
1966 }
1967
1968 pub fn apply_local_flags_many(&mut self, updates: &[(MessageId, MessageFlags)]) {
1969 for (message_id, flags) in updates {
1970 self.apply_local_flags(message_id, *flags);
1971 }
1972 }
1973
1974 fn apply_local_mutation_effect(&mut self, effect: &MutationEffect) {
1975 match effect {
1976 MutationEffect::UpdateFlags { message_id, flags } => {
1977 self.apply_local_flags(message_id, *flags);
1978 }
1979 MutationEffect::UpdateFlagsMany { updates } => {
1980 self.apply_local_flags_many(updates);
1981 }
1982 MutationEffect::ModifyLabels {
1983 message_ids,
1984 add,
1985 remove,
1986 ..
1987 } => {
1988 self.apply_local_label_refs(message_ids, add, remove);
1989 }
1990 MutationEffect::RemoveFromList(_)
1991 | MutationEffect::RemoveFromListMany(_)
1992 | MutationEffect::RefreshList
1993 | MutationEffect::StatusOnly(_) => {}
1994 }
1995 }
1996
1997 fn queue_mutation(&mut self, request: Request, effect: MutationEffect, status_message: String) {
1998 self.pending_mutation_queue.push((request, effect));
1999 self.pending_mutation_count += 1;
2000 self.pending_mutation_status = Some(status_message.clone());
2001 self.status_message = Some(status_message);
2002 }
2003
2004 pub fn finish_pending_mutation(&mut self) {
2005 self.pending_mutation_count = self.pending_mutation_count.saturating_sub(1);
2006 if self.pending_mutation_count == 0 {
2007 self.pending_mutation_status = None;
2008 }
2009 }
2010
2011 pub fn show_mutation_failure(&mut self, error: &MxrError) {
2012 self.error_modal = Some(ErrorModalState {
2013 title: "Mutation Failed".into(),
2014 detail: format!(
2015 "Optimistic changes could not be applied.\nMailbox is refreshing to reconcile state.\n\n{error}"
2016 ),
2017 });
2018 self.status_message = Some(format!("Error: {error}"));
2019 }
2020
2021 pub fn refresh_mailbox_after_mutation_failure(&mut self) {
2022 self.pending_labels_refresh = true;
2023 self.pending_all_envelopes_refresh = true;
2024 self.pending_status_refresh = true;
2025 self.pending_subscriptions_refresh = true;
2026 if let Some(label_id) = self.active_label.clone() {
2027 self.pending_label_fetch = Some(label_id);
2028 }
2029 }
2030
2031 fn message_flags(&self, message_id: &MessageId) -> Option<MessageFlags> {
2032 self.envelopes
2033 .iter()
2034 .chain(self.all_envelopes.iter())
2035 .chain(self.search_page.results.iter())
2036 .chain(self.viewed_thread_messages.iter())
2037 .find(|envelope| &envelope.id == message_id)
2038 .map(|envelope| envelope.flags)
2039 .or_else(|| {
2040 self.viewing_envelope
2041 .as_ref()
2042 .filter(|envelope| &envelope.id == message_id)
2043 .map(|envelope| envelope.flags)
2044 })
2045 }
2046
2047 fn flag_updates_for_ids<F>(
2048 &self,
2049 message_ids: &[MessageId],
2050 mut update: F,
2051 ) -> Vec<(MessageId, MessageFlags)>
2052 where
2053 F: FnMut(MessageFlags) -> MessageFlags,
2054 {
2055 message_ids
2056 .iter()
2057 .filter_map(|message_id| {
2058 self.message_flags(message_id)
2059 .map(|flags| (message_id.clone(), update(flags)))
2060 })
2061 .collect()
2062 }
2063
2064 fn resolve_label_provider_ids(&self, refs: &[String]) -> Vec<String> {
2065 refs.iter()
2066 .filter_map(|label_ref| {
2067 self.labels
2068 .iter()
2069 .find(|label| label.provider_id == *label_ref || label.name == *label_ref)
2070 .map(|label| label.provider_id.clone())
2071 .or_else(|| Some(label_ref.clone()))
2072 })
2073 .collect()
2074 }
2075
2076 pub fn resolve_thread_success(&mut self, thread: Thread, mut messages: Vec<Envelope>) {
2077 let thread_id = thread.id.clone();
2078 self.in_flight_thread_fetch = None;
2079 messages.sort_by_key(|message| message.date);
2080
2081 if self
2082 .viewing_envelope
2083 .as_ref()
2084 .map(|env| env.thread_id.clone())
2085 == Some(thread_id)
2086 {
2087 let focused_message_id = self.focused_thread_envelope().map(|env| env.id.clone());
2088 for message in &messages {
2089 self.queue_body_fetch(message.id.clone());
2090 }
2091 self.viewed_thread = Some(thread);
2092 self.viewed_thread_messages = messages;
2093 self.thread_selected_index = focused_message_id
2094 .and_then(|message_id| {
2095 self.viewed_thread_messages
2096 .iter()
2097 .position(|message| message.id == message_id)
2098 })
2099 .unwrap_or_else(|| self.default_thread_selected_index());
2100 self.sync_focused_thread_envelope();
2101 }
2102 }
2103
2104 pub fn resolve_thread_fetch_error(&mut self, thread_id: &mxr_core::ThreadId) {
2105 if self.in_flight_thread_fetch.as_ref() == Some(thread_id) {
2106 self.in_flight_thread_fetch = None;
2107 }
2108 }
2109
2110 fn mutation_target_ids(&self) -> Vec<MessageId> {
2112 if !self.selected_set.is_empty() {
2113 self.selected_set.iter().cloned().collect()
2114 } else if let Some(env) = self.context_envelope() {
2115 vec![env.id.clone()]
2116 } else {
2117 vec![]
2118 }
2119 }
2120
2121 fn clear_selection(&mut self) {
2122 self.selected_set.clear();
2123 self.visual_mode = false;
2124 self.visual_anchor = None;
2125 }
2126
2127 fn queue_or_confirm_bulk_action(
2128 &mut self,
2129 title: impl Into<String>,
2130 detail: impl Into<String>,
2131 request: Request,
2132 effect: MutationEffect,
2133 optimistic_effect: Option<MutationEffect>,
2134 status_message: String,
2135 count: usize,
2136 ) {
2137 if count > 1 {
2138 self.pending_bulk_confirm = Some(PendingBulkConfirm {
2139 title: title.into(),
2140 detail: detail.into(),
2141 request,
2142 effect,
2143 optimistic_effect,
2144 status_message,
2145 });
2146 } else {
2147 if let Some(effect) = optimistic_effect.as_ref() {
2148 self.apply_local_mutation_effect(effect);
2149 }
2150 self.queue_mutation(request, effect, status_message);
2151 self.clear_selection();
2152 }
2153 }
2154
2155 fn update_visual_selection(&mut self) {
2157 if self.visual_mode {
2158 if let Some(anchor) = self.visual_anchor {
2159 let start = anchor.min(self.selected_index);
2160 let end = anchor.max(self.selected_index);
2161 self.selected_set.clear();
2162 for env in self.envelopes.iter().skip(start).take(end - start + 1) {
2163 self.selected_set.insert(env.id.clone());
2164 }
2165 }
2166 }
2167 }
2168
2169 fn ensure_visible(&mut self) {
2171 let h = self.visible_height.max(1);
2172 if self.selected_index < self.scroll_offset {
2173 self.scroll_offset = self.selected_index;
2174 } else if self.selected_index >= self.scroll_offset + h {
2175 self.scroll_offset = self.selected_index + 1 - h;
2176 }
2177 self.queue_body_window();
2179 }
2180
2181 pub fn set_subscriptions(&mut self, subscriptions: Vec<SubscriptionSummary>) {
2182 let selected_id = self
2183 .selected_subscription_entry()
2184 .map(|entry| entry.summary.latest_message_id.clone());
2185 self.subscriptions_page.entries = subscriptions
2186 .into_iter()
2187 .map(|summary| SubscriptionEntry {
2188 envelope: subscription_summary_to_envelope(&summary),
2189 summary,
2190 })
2191 .collect();
2192
2193 if self.subscriptions_page.entries.is_empty() {
2194 if self.mailbox_view == MailboxView::Subscriptions {
2195 self.selected_index = 0;
2196 self.scroll_offset = 0;
2197 self.viewing_envelope = None;
2198 self.viewed_thread = None;
2199 self.viewed_thread_messages.clear();
2200 self.body_view_state = BodyViewState::Empty { preview: None };
2201 }
2202 return;
2203 }
2204
2205 if let Some(selected_id) = selected_id {
2206 if let Some(position) = self
2207 .subscriptions_page
2208 .entries
2209 .iter()
2210 .position(|entry| entry.summary.latest_message_id == selected_id)
2211 {
2212 self.selected_index = position;
2213 } else {
2214 self.selected_index = self
2215 .selected_index
2216 .min(self.subscriptions_page.entries.len().saturating_sub(1));
2217 }
2218 } else {
2219 self.selected_index = self
2220 .selected_index
2221 .min(self.subscriptions_page.entries.len().saturating_sub(1));
2222 }
2223
2224 if self.mailbox_view == MailboxView::Subscriptions {
2225 self.ensure_visible();
2226 self.auto_preview();
2227 }
2228 }
2229}
2230
2231fn apply_provider_label_changes(
2232 label_provider_ids: &mut Vec<String>,
2233 add_provider_ids: &[String],
2234 remove_provider_ids: &[String],
2235) {
2236 label_provider_ids.retain(|provider_id| {
2237 !remove_provider_ids
2238 .iter()
2239 .any(|remove| remove == provider_id)
2240 });
2241 for provider_id in add_provider_ids {
2242 if !label_provider_ids
2243 .iter()
2244 .any(|existing| existing == provider_id)
2245 {
2246 label_provider_ids.push(provider_id.clone());
2247 }
2248 }
2249}
2250
2251fn unsubscribe_method_label(method: &UnsubscribeMethod) -> &'static str {
2252 match method {
2253 UnsubscribeMethod::OneClick { .. } => "one-click",
2254 UnsubscribeMethod::Mailto { .. } => "mailto",
2255 UnsubscribeMethod::HttpLink { .. } => "browser link",
2256 UnsubscribeMethod::BodyLink { .. } => "body link",
2257 UnsubscribeMethod::None => "none",
2258 }
2259}
2260
2261fn remove_from_list_effect(ids: &[MessageId]) -> MutationEffect {
2262 if ids.len() == 1 {
2263 MutationEffect::RemoveFromList(ids[0].clone())
2264 } else {
2265 MutationEffect::RemoveFromListMany(ids.to_vec())
2266 }
2267}
2268
2269fn pluralize_messages(count: usize) -> &'static str {
2270 if count == 1 {
2271 "message"
2272 } else {
2273 "messages"
2274 }
2275}
2276
2277fn bulk_message_detail(verb: &str, count: usize) -> String {
2278 format!(
2279 "You are about to {verb} these {count} {}.",
2280 pluralize_messages(count)
2281 )
2282}
2283
2284fn subscription_summary_to_envelope(summary: &SubscriptionSummary) -> Envelope {
2285 Envelope {
2286 id: summary.latest_message_id.clone(),
2287 account_id: summary.account_id.clone(),
2288 provider_id: summary.latest_provider_id.clone(),
2289 thread_id: summary.latest_thread_id.clone(),
2290 message_id_header: None,
2291 in_reply_to: None,
2292 references: vec![],
2293 from: Address {
2294 name: summary.sender_name.clone(),
2295 email: summary.sender_email.clone(),
2296 },
2297 to: vec![],
2298 cc: vec![],
2299 bcc: vec![],
2300 subject: summary.latest_subject.clone(),
2301 date: summary.latest_date,
2302 flags: summary.latest_flags,
2303 snippet: summary.latest_snippet.clone(),
2304 has_attachments: summary.latest_has_attachments,
2305 size_bytes: summary.latest_size_bytes,
2306 unsubscribe: summary.unsubscribe.clone(),
2307 label_provider_ids: vec![],
2308 }
2309}
2310
2311fn account_summary_to_config(
2312 account: &mxr_protocol::AccountSummaryData,
2313) -> Option<mxr_protocol::AccountConfigData> {
2314 Some(mxr_protocol::AccountConfigData {
2315 key: account.key.clone()?,
2316 name: account.name.clone(),
2317 email: account.email.clone(),
2318 sync: account.sync.clone(),
2319 send: account.send.clone(),
2320 is_default: account.is_default,
2321 })
2322}
2323
2324fn account_form_from_config(account: mxr_protocol::AccountConfigData) -> AccountFormState {
2325 let mut form = AccountFormState {
2326 visible: true,
2327 key: account.key,
2328 name: account.name,
2329 email: account.email,
2330 ..AccountFormState::default()
2331 };
2332
2333 if let Some(sync) = account.sync {
2334 match sync {
2335 mxr_protocol::AccountSyncConfigData::Gmail {
2336 credential_source,
2337 client_id,
2338 client_secret,
2339 token_ref,
2340 } => {
2341 form.mode = AccountFormMode::Gmail;
2342 form.gmail_credential_source = credential_source;
2343 form.gmail_client_id = client_id;
2344 form.gmail_client_secret = client_secret.unwrap_or_default();
2345 form.gmail_token_ref = token_ref;
2346 }
2347 mxr_protocol::AccountSyncConfigData::Imap {
2348 host,
2349 port,
2350 username,
2351 password_ref,
2352 ..
2353 } => {
2354 form.mode = AccountFormMode::ImapSmtp;
2355 form.imap_host = host;
2356 form.imap_port = port.to_string();
2357 form.imap_username = username;
2358 form.imap_password_ref = password_ref;
2359 }
2360 }
2361 } else {
2362 form.mode = AccountFormMode::SmtpOnly;
2363 }
2364
2365 match account.send {
2366 Some(mxr_protocol::AccountSendConfigData::Smtp {
2367 host,
2368 port,
2369 username,
2370 password_ref,
2371 ..
2372 }) => {
2373 form.smtp_host = host;
2374 form.smtp_port = port.to_string();
2375 form.smtp_username = username;
2376 form.smtp_password_ref = password_ref;
2377 }
2378 Some(mxr_protocol::AccountSendConfigData::Gmail) => {
2379 if form.gmail_token_ref.is_empty() {
2380 form.gmail_token_ref = format!("mxr/{}-gmail", form.key);
2381 }
2382 }
2383 None => {}
2384 }
2385
2386 form
2387}
2388
2389fn account_form_field_value(form: &AccountFormState) -> Option<&str> {
2390 match (form.mode, form.active_field) {
2391 (_, 0) => None,
2392 (_, 1) => Some(form.key.as_str()),
2393 (_, 2) => Some(form.name.as_str()),
2394 (_, 3) => Some(form.email.as_str()),
2395 (AccountFormMode::Gmail, 4) => None,
2396 (AccountFormMode::Gmail, 5)
2397 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2398 {
2399 Some(form.gmail_client_id.as_str())
2400 }
2401 (AccountFormMode::Gmail, 6)
2402 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2403 {
2404 Some(form.gmail_client_secret.as_str())
2405 }
2406 (AccountFormMode::Gmail, 5 | 6) => None,
2407 (AccountFormMode::Gmail, 7) => None,
2408 (AccountFormMode::ImapSmtp, 4) => Some(form.imap_host.as_str()),
2409 (AccountFormMode::ImapSmtp, 5) => Some(form.imap_port.as_str()),
2410 (AccountFormMode::ImapSmtp, 6) => Some(form.imap_username.as_str()),
2411 (AccountFormMode::ImapSmtp, 7) => Some(form.imap_password_ref.as_str()),
2412 (AccountFormMode::ImapSmtp, 8) => Some(form.imap_password.as_str()),
2413 (AccountFormMode::ImapSmtp, 9) => Some(form.smtp_host.as_str()),
2414 (AccountFormMode::ImapSmtp, 10) => Some(form.smtp_port.as_str()),
2415 (AccountFormMode::ImapSmtp, 11) => Some(form.smtp_username.as_str()),
2416 (AccountFormMode::ImapSmtp, 12) => Some(form.smtp_password_ref.as_str()),
2417 (AccountFormMode::ImapSmtp, 13) => Some(form.smtp_password.as_str()),
2418 (AccountFormMode::SmtpOnly, 4) => Some(form.smtp_host.as_str()),
2419 (AccountFormMode::SmtpOnly, 5) => Some(form.smtp_port.as_str()),
2420 (AccountFormMode::SmtpOnly, 6) => Some(form.smtp_username.as_str()),
2421 (AccountFormMode::SmtpOnly, 7) => Some(form.smtp_password_ref.as_str()),
2422 (AccountFormMode::SmtpOnly, 8) => Some(form.smtp_password.as_str()),
2423 _ => None,
2424 }
2425}
2426
2427fn account_form_field_is_editable(form: &AccountFormState) -> bool {
2428 account_form_field_value(form).is_some()
2429}
2430
2431fn with_account_form_field_mut<F>(form: &mut AccountFormState, mut update: F)
2432where
2433 F: FnMut(&mut String),
2434{
2435 let field = match (form.mode, form.active_field) {
2436 (_, 1) => &mut form.key,
2437 (_, 2) => &mut form.name,
2438 (_, 3) => &mut form.email,
2439 (AccountFormMode::Gmail, 5)
2440 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2441 {
2442 &mut form.gmail_client_id
2443 }
2444 (AccountFormMode::Gmail, 6)
2445 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2446 {
2447 &mut form.gmail_client_secret
2448 }
2449 (AccountFormMode::ImapSmtp, 4) => &mut form.imap_host,
2450 (AccountFormMode::ImapSmtp, 5) => &mut form.imap_port,
2451 (AccountFormMode::ImapSmtp, 6) => &mut form.imap_username,
2452 (AccountFormMode::ImapSmtp, 7) => &mut form.imap_password_ref,
2453 (AccountFormMode::ImapSmtp, 8) => &mut form.imap_password,
2454 (AccountFormMode::ImapSmtp, 9) => &mut form.smtp_host,
2455 (AccountFormMode::ImapSmtp, 10) => &mut form.smtp_port,
2456 (AccountFormMode::ImapSmtp, 11) => &mut form.smtp_username,
2457 (AccountFormMode::ImapSmtp, 12) => &mut form.smtp_password_ref,
2458 (AccountFormMode::ImapSmtp, 13) => &mut form.smtp_password,
2459 (AccountFormMode::SmtpOnly, 4) => &mut form.smtp_host,
2460 (AccountFormMode::SmtpOnly, 5) => &mut form.smtp_port,
2461 (AccountFormMode::SmtpOnly, 6) => &mut form.smtp_username,
2462 (AccountFormMode::SmtpOnly, 7) => &mut form.smtp_password_ref,
2463 (AccountFormMode::SmtpOnly, 8) => &mut form.smtp_password,
2464 _ => return,
2465 };
2466 update(field);
2467}
2468
2469fn insert_account_form_char(form: &mut AccountFormState, c: char) {
2470 let cursor = form.field_cursor;
2471 with_account_form_field_mut(form, |value| {
2472 let insert_at = char_to_byte_index(value, cursor);
2473 value.insert(insert_at, c);
2474 });
2475 form.field_cursor = form.field_cursor.saturating_add(1);
2476}
2477
2478fn delete_account_form_char(form: &mut AccountFormState, backspace: bool) {
2479 let cursor = form.field_cursor;
2480 with_account_form_field_mut(form, |value| {
2481 if backspace {
2482 if cursor == 0 {
2483 return;
2484 }
2485 let start = char_to_byte_index(value, cursor - 1);
2486 let end = char_to_byte_index(value, cursor);
2487 value.replace_range(start..end, "");
2488 } else {
2489 let len = value.chars().count();
2490 if cursor >= len {
2491 return;
2492 }
2493 let start = char_to_byte_index(value, cursor);
2494 let end = char_to_byte_index(value, cursor + 1);
2495 value.replace_range(start..end, "");
2496 }
2497 });
2498 if backspace {
2499 form.field_cursor = form.field_cursor.saturating_sub(1);
2500 }
2501}
2502
2503fn char_to_byte_index(value: &str, char_index: usize) -> usize {
2504 value
2505 .char_indices()
2506 .nth(char_index)
2507 .map(|(index, _)| index)
2508 .unwrap_or(value.len())
2509}
2510
2511fn next_gmail_credential_source(
2512 current: mxr_protocol::GmailCredentialSourceData,
2513 forward: bool,
2514) -> mxr_protocol::GmailCredentialSourceData {
2515 match (current, forward) {
2516 (mxr_protocol::GmailCredentialSourceData::Bundled, true) => {
2517 mxr_protocol::GmailCredentialSourceData::Custom
2518 }
2519 (mxr_protocol::GmailCredentialSourceData::Custom, true) => {
2520 mxr_protocol::GmailCredentialSourceData::Bundled
2521 }
2522 (mxr_protocol::GmailCredentialSourceData::Bundled, false) => {
2523 mxr_protocol::GmailCredentialSourceData::Custom
2524 }
2525 (mxr_protocol::GmailCredentialSourceData::Custom, false) => {
2526 mxr_protocol::GmailCredentialSourceData::Bundled
2527 }
2528 }
2529}
2530
2531pub fn snooze_presets() -> [SnoozePreset; 4] {
2532 [
2533 SnoozePreset::TomorrowMorning,
2534 SnoozePreset::Tonight,
2535 SnoozePreset::Weekend,
2536 SnoozePreset::NextMonday,
2537 ]
2538}
2539
2540pub fn resolve_snooze_preset(
2541 preset: SnoozePreset,
2542 config: &mxr_config::SnoozeConfig,
2543) -> chrono::DateTime<chrono::Utc> {
2544 use chrono::{Datelike, Duration, Local, NaiveTime, Weekday};
2545
2546 let now = Local::now();
2547 match preset {
2548 SnoozePreset::TomorrowMorning => {
2549 let tomorrow = now.date_naive() + Duration::days(1);
2550 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2551 tomorrow
2552 .and_time(time)
2553 .and_local_timezone(now.timezone())
2554 .single()
2555 .unwrap()
2556 .with_timezone(&chrono::Utc)
2557 }
2558 SnoozePreset::Tonight => {
2559 let today = now.date_naive();
2560 let time = NaiveTime::from_hms_opt(config.evening_hour as u32, 0, 0).unwrap();
2561 let tonight = today
2562 .and_time(time)
2563 .and_local_timezone(now.timezone())
2564 .single()
2565 .unwrap()
2566 .with_timezone(&chrono::Utc);
2567 if tonight <= chrono::Utc::now() {
2568 tonight + Duration::days(1)
2569 } else {
2570 tonight
2571 }
2572 }
2573 SnoozePreset::Weekend => {
2574 let target_day = match config.weekend_day.as_str() {
2575 "sunday" => Weekday::Sun,
2576 _ => Weekday::Sat,
2577 };
2578 let days_until = (target_day.num_days_from_monday() as i64
2579 - now.weekday().num_days_from_monday() as i64
2580 + 7)
2581 % 7;
2582 let days = if days_until == 0 { 7 } else { days_until };
2583 let weekend = now.date_naive() + Duration::days(days);
2584 let time = NaiveTime::from_hms_opt(config.weekend_hour as u32, 0, 0).unwrap();
2585 weekend
2586 .and_time(time)
2587 .and_local_timezone(now.timezone())
2588 .single()
2589 .unwrap()
2590 .with_timezone(&chrono::Utc)
2591 }
2592 SnoozePreset::NextMonday => {
2593 let days_until_monday = (Weekday::Mon.num_days_from_monday() as i64
2594 - now.weekday().num_days_from_monday() as i64
2595 + 7)
2596 % 7;
2597 let days = if days_until_monday == 0 {
2598 7
2599 } else {
2600 days_until_monday
2601 };
2602 let monday = now.date_naive() + Duration::days(days);
2603 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2604 monday
2605 .and_time(time)
2606 .and_local_timezone(now.timezone())
2607 .single()
2608 .unwrap()
2609 .with_timezone(&chrono::Utc)
2610 }
2611 }
2612}
2613
2614#[cfg(test)]
2615mod tests {
2616 use super::*;
2617 use chrono::TimeZone;
2618
2619 fn test_envelope(
2620 thread_id: mxr_core::ThreadId,
2621 subject: &str,
2622 date: chrono::DateTime<chrono::Utc>,
2623 ) -> Envelope {
2624 Envelope {
2625 id: MessageId::new(),
2626 account_id: AccountId::new(),
2627 provider_id: subject.to_string(),
2628 thread_id,
2629 message_id_header: None,
2630 in_reply_to: None,
2631 references: vec![],
2632 from: Address {
2633 name: Some("Alice".to_string()),
2634 email: "alice@example.com".to_string(),
2635 },
2636 to: vec![],
2637 cc: vec![],
2638 bcc: vec![],
2639 subject: subject.to_string(),
2640 date,
2641 flags: MessageFlags::empty(),
2642 snippet: String::new(),
2643 has_attachments: false,
2644 size_bytes: 0,
2645 unsubscribe: UnsubscribeMethod::None,
2646 label_provider_ids: vec![],
2647 }
2648 }
2649
2650 #[test]
2651 fn build_mail_list_rows_ignores_impossible_future_thread_dates() {
2652 let thread_id = mxr_core::ThreadId::new();
2653 let poisoned = test_envelope(
2654 thread_id.clone(),
2655 "Poisoned future",
2656 chrono::Utc
2657 .timestamp_opt(236_816_444_325, 0)
2658 .single()
2659 .unwrap(),
2660 );
2661 let recent = test_envelope(thread_id, "Real recent", chrono::Utc::now());
2662
2663 let rows = App::build_mail_list_rows(&[poisoned, recent.clone()], MailListMode::Threads);
2664
2665 assert_eq!(rows.len(), 1);
2666 assert_eq!(rows[0].representative.subject, recent.subject);
2667 }
2668}