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