Skip to main content

mxr_tui/app/
mod.rs

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
56/// Draft waiting for user confirmation after editor closes.
57pub 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    /// Get the contextual envelope: the one being viewed, or the selected one.
977    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        // Queue body prefetch for first visible window
1017        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    /// Number of visible (non-hidden) labels.
1123    pub fn visible_label_count(&self) -> usize {
1124        self.ordered_visible_labels().len()
1125    }
1126
1127    /// Get the visible (filtered) labels.
1128    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    /// Live filter: instant client-side prefix matching on subject/from/snippet,
1201    /// plus async Tantivy search for full-text body matches.
1202    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            // Instant client-side filter: every query word must prefix-match
1210            // some word in subject, from, or snippet
1211            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            // Also fire async Tantivy search to catch body matches
1233            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    /// Compute the mail list title based on active filter/search.
1240    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    /// In ThreePane mode, auto-load the preview for the currently selected envelope.
1432    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    /// Queue body prefetch for messages around the current cursor position.
1480    /// Only fetches bodies not already in cache.
1481    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    /// Get IDs to mutate: selected_set if non-empty, else context_envelope.
2111    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    /// Update visual selection range when moving in visual mode.
2156    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    /// Ensure selected_index is visible within the scroll viewport.
2170    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        // Prefetch bodies for messages near the cursor
2178        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}