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