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                let mut filtered = filtered;
1458                filtered.sort_by(|left, right| {
1459                    sane_mail_sort_timestamp(&right.date)
1460                        .cmp(&sane_mail_sort_timestamp(&left.date))
1461                        .then_with(|| right.id.as_str().cmp(&left.id.as_str()))
1462                });
1463                self.search_page.results = filtered
1464                    .into_iter()
1465                    .take(SEARCH_PAGE_SIZE as usize)
1466                    .collect();
1467                self.search_page.scores.clear();
1468                self.search_page.has_more = false;
1469                self.search_page.loading_more = true;
1470                self.search_page.load_to_end = false;
1471                self.search_page.session_active = true;
1472                self.search_page.active_pane = SearchPane::Results;
1473                let session_id = Self::bump_search_session_id(&mut self.search_page.session_id);
1474                self.queue_search_request(
1475                    SearchTarget::SearchPage,
1476                    false,
1477                    query_source,
1478                    self.search_page.mode,
1479                    self.search_page.sort.clone(),
1480                    0,
1481                    session_id,
1482                );
1483            } else {
1484                let mut filtered = filtered;
1485                filtered.sort_by(|left, right| {
1486                    sane_mail_sort_timestamp(&right.date)
1487                        .cmp(&sane_mail_sort_timestamp(&left.date))
1488                        .then_with(|| right.id.as_str().cmp(&left.id.as_str()))
1489                });
1490                self.envelopes = filtered;
1491                self.search_active = true;
1492                let session_id = Self::bump_search_session_id(&mut self.mailbox_search_session_id);
1493                self.queue_search_request(
1494                    SearchTarget::Mailbox,
1495                    false,
1496                    query_source,
1497                    self.search_bar.mode,
1498                    SortOrder::DateDesc,
1499                    0,
1500                    session_id,
1501                );
1502            }
1503        }
1504        if self.screen == Screen::Search {
1505            self.search_page.selected_index = 0;
1506            self.search_page.scroll_offset = 0;
1507            self.auto_preview_search();
1508        } else {
1509            self.selected_index = 0;
1510            self.scroll_offset = 0;
1511        }
1512    }
1513
1514    /// Compute the mail list title based on active filter/search.
1515    pub fn mail_list_title(&self) -> String {
1516        if self.mailbox_view == MailboxView::Subscriptions {
1517            return format!("Subscriptions ({})", self.subscriptions_page.entries.len());
1518        }
1519
1520        let list_name = match self.mail_list_mode {
1521            MailListMode::Threads => "Threads",
1522            MailListMode::Messages => "Messages",
1523        };
1524        let list_count = self.mail_row_count();
1525        if self.search_active {
1526            format!("Search: {} ({list_count})", self.search_bar.query)
1527        } else if let Some(label_id) = self
1528            .pending_active_label
1529            .as_ref()
1530            .or(self.active_label.as_ref())
1531        {
1532            if let Some(label) = self.labels.iter().find(|l| &l.id == label_id) {
1533                let name = crate::ui::sidebar::humanize_label(&label.name);
1534                format!("{name} {list_name} ({list_count})")
1535            } else {
1536                format!("{list_name} ({list_count})")
1537            }
1538        } else {
1539            format!("All Mail {list_name} ({list_count})")
1540        }
1541    }
1542
1543    fn all_mail_envelopes(&self) -> Vec<Envelope> {
1544        self.all_envelopes
1545            .iter()
1546            .filter(|envelope| !envelope.flags.contains(MessageFlags::TRASH))
1547            .cloned()
1548            .collect()
1549    }
1550
1551    fn active_label_record(&self) -> Option<&Label> {
1552        let label_id = self
1553            .pending_active_label
1554            .as_ref()
1555            .or(self.active_label.as_ref())?;
1556        self.labels.iter().find(|label| &label.id == label_id)
1557    }
1558
1559    fn global_starred_count(&self) -> usize {
1560        self.labels
1561            .iter()
1562            .find(|label| label.name.eq_ignore_ascii_case("STARRED"))
1563            .map(|label| label.total_count as usize)
1564            .unwrap_or_else(|| {
1565                self.all_envelopes
1566                    .iter()
1567                    .filter(|envelope| envelope.flags.contains(MessageFlags::STARRED))
1568                    .count()
1569            })
1570    }
1571
1572    pub fn status_bar_state(&self) -> ui::status_bar::StatusBarState {
1573        let starred_count = self.global_starred_count();
1574
1575        if self.mailbox_view == MailboxView::Subscriptions {
1576            let unread_count = self
1577                .subscriptions_page
1578                .entries
1579                .iter()
1580                .filter(|entry| !entry.envelope.flags.contains(MessageFlags::READ))
1581                .count();
1582            return ui::status_bar::StatusBarState {
1583                mailbox_name: "SUBSCRIPTIONS".into(),
1584                total_count: self.subscriptions_page.entries.len(),
1585                unread_count,
1586                starred_count,
1587                sync_status: self.last_sync_status.clone(),
1588                status_message: self.status_message.clone(),
1589                pending_mutation_count: self.pending_mutation_count,
1590                pending_mutation_status: self.pending_mutation_status.clone(),
1591            };
1592        }
1593
1594        if self.screen == Screen::Search || self.search_active {
1595            let results = if self.screen == Screen::Search {
1596                &self.search_page.results
1597            } else {
1598                &self.envelopes
1599            };
1600            let unread_count = results
1601                .iter()
1602                .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1603                .count();
1604            return ui::status_bar::StatusBarState {
1605                mailbox_name: "SEARCH".into(),
1606                total_count: results.len(),
1607                unread_count,
1608                starred_count,
1609                sync_status: self.last_sync_status.clone(),
1610                status_message: self.status_message.clone(),
1611                pending_mutation_count: self.pending_mutation_count,
1612                pending_mutation_status: self.pending_mutation_status.clone(),
1613            };
1614        }
1615
1616        if let Some(label) = self.active_label_record() {
1617            return ui::status_bar::StatusBarState {
1618                mailbox_name: label.name.clone(),
1619                total_count: label.total_count as usize,
1620                unread_count: label.unread_count as usize,
1621                starred_count,
1622                sync_status: self.last_sync_status.clone(),
1623                status_message: self.status_message.clone(),
1624                pending_mutation_count: self.pending_mutation_count,
1625                pending_mutation_status: self.pending_mutation_status.clone(),
1626            };
1627        }
1628
1629        let unread_count = self
1630            .envelopes
1631            .iter()
1632            .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1633            .count();
1634        ui::status_bar::StatusBarState {
1635            mailbox_name: "ALL MAIL".into(),
1636            total_count: self
1637                .diagnostics_page
1638                .total_messages
1639                .map(|count| count as usize)
1640                .unwrap_or_else(|| self.all_envelopes.len()),
1641            unread_count,
1642            starred_count,
1643            sync_status: self.last_sync_status.clone(),
1644            status_message: self.status_message.clone(),
1645            pending_mutation_count: self.pending_mutation_count,
1646            pending_mutation_status: self.pending_mutation_status.clone(),
1647        }
1648    }
1649
1650    fn summarize_sync_status(sync_statuses: &[mxr_protocol::AccountSyncStatus]) -> String {
1651        if sync_statuses.is_empty() {
1652            return "not synced".into();
1653        }
1654        if sync_statuses.iter().any(|sync| sync.sync_in_progress) {
1655            return "syncing".into();
1656        }
1657        if sync_statuses
1658            .iter()
1659            .any(|sync| !sync.healthy || sync.last_error.is_some())
1660        {
1661            return "degraded".into();
1662        }
1663        sync_statuses
1664            .iter()
1665            .filter_map(|sync| sync.last_success_at.as_deref())
1666            .filter_map(Self::format_sync_age)
1667            .max_by_key(|(_, sort_key)| *sort_key)
1668            .map(|(display, _)| format!("synced {display}"))
1669            .unwrap_or_else(|| "not synced".into())
1670    }
1671
1672    fn format_sync_age(timestamp: &str) -> Option<(String, i64)> {
1673        let parsed = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?;
1674        let synced_at = parsed.with_timezone(&chrono::Utc);
1675        let elapsed = chrono::Utc::now().signed_duration_since(synced_at);
1676        let seconds = elapsed.num_seconds().max(0);
1677        let display = if seconds < 60 {
1678            "just now".to_string()
1679        } else if seconds < 3_600 {
1680            format!("{}m ago", seconds / 60)
1681        } else if seconds < 86_400 {
1682            format!("{}h ago", seconds / 3_600)
1683        } else {
1684            format!("{}d ago", seconds / 86_400)
1685        };
1686        Some((display, synced_at.timestamp()))
1687    }
1688
1689    pub fn resolve_desired_system_mailbox(&mut self) {
1690        let Some(target) = self.desired_system_mailbox.as_deref() else {
1691            return;
1692        };
1693        if self.pending_active_label.is_some() || self.active_label.is_some() {
1694            return;
1695        }
1696        if let Some(label_id) = self
1697            .labels
1698            .iter()
1699            .find(|label| label.name.eq_ignore_ascii_case(target))
1700            .map(|label| label.id.clone())
1701        {
1702            self.apply(Action::SelectLabel(label_id));
1703        }
1704    }
1705
1706    /// In ThreePane mode, auto-load the preview for the currently selected envelope.
1707    fn auto_preview(&mut self) {
1708        if self.mailbox_view == MailboxView::Subscriptions {
1709            if let Some(entry) = self.selected_subscription_entry().cloned() {
1710                if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&entry.envelope.id) {
1711                    self.open_envelope(entry.envelope);
1712                }
1713            } else {
1714                self.pending_preview_read = None;
1715                self.viewing_envelope = None;
1716                self.viewed_thread = None;
1717                self.viewed_thread_messages.clear();
1718                self.body_view_state = BodyViewState::Empty { preview: None };
1719            }
1720            return;
1721        }
1722
1723        if self.layout_mode == LayoutMode::ThreePane {
1724            if let Some(row) = self.selected_mail_row() {
1725                if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&row.representative.id) {
1726                    self.open_envelope(row.representative);
1727                }
1728            }
1729        }
1730    }
1731
1732    pub fn auto_preview_search(&mut self) {
1733        if let Some(env) = self.selected_search_envelope().cloned() {
1734            if self
1735                .viewing_envelope
1736                .as_ref()
1737                .map(|current| current.id.clone())
1738                != Some(env.id.clone())
1739            {
1740                self.open_envelope(env);
1741            }
1742        } else if self.screen == Screen::Search {
1743            self.clear_message_view_state();
1744        }
1745    }
1746
1747    pub(crate) fn ensure_search_visible(&mut self) {
1748        let h = self.visible_height.max(1);
1749        if self.search_page.selected_index < self.search_page.scroll_offset {
1750            self.search_page.scroll_offset = self.search_page.selected_index;
1751        } else if self.search_page.selected_index >= self.search_page.scroll_offset + h {
1752            self.search_page.scroll_offset = self.search_page.selected_index + 1 - h;
1753        }
1754    }
1755
1756    /// Queue body prefetch for messages around the current cursor position.
1757    /// Only fetches bodies not already in cache.
1758    pub fn queue_body_window(&mut self) {
1759        const BUFFER: usize = 50;
1760        let source_envelopes: Vec<Envelope> = if self.mailbox_view == MailboxView::Subscriptions {
1761            self.subscriptions_page
1762                .entries
1763                .iter()
1764                .map(|entry| entry.envelope.clone())
1765                .collect()
1766        } else {
1767            self.envelopes.clone()
1768        };
1769        let len = source_envelopes.len();
1770        if len == 0 {
1771            return;
1772        }
1773        let start = self.selected_index.saturating_sub(BUFFER / 2);
1774        let end = (self.selected_index + BUFFER / 2).min(len);
1775        let ids: Vec<MessageId> = source_envelopes[start..end]
1776            .iter()
1777            .map(|e| e.id.clone())
1778            .collect();
1779        for id in ids {
1780            self.queue_body_fetch(id);
1781        }
1782    }
1783
1784    fn open_envelope(&mut self, env: Envelope) {
1785        self.close_attachment_panel();
1786        self.signature_expanded = false;
1787        self.viewed_thread = None;
1788        self.viewed_thread_messages = self.optimistic_thread_messages(&env);
1789        self.thread_selected_index = self.default_thread_selected_index();
1790        self.viewing_envelope = self.focused_thread_envelope().cloned();
1791        if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1792            self.schedule_preview_read(&viewing_envelope);
1793        }
1794        for message in self.viewed_thread_messages.clone() {
1795            self.queue_body_fetch(message.id);
1796        }
1797        self.queue_thread_fetch(env.thread_id.clone());
1798        self.message_scroll_offset = 0;
1799        self.ensure_current_body_state();
1800    }
1801
1802    fn optimistic_thread_messages(&self, env: &Envelope) -> Vec<Envelope> {
1803        let mut messages: Vec<Envelope> = self
1804            .all_envelopes
1805            .iter()
1806            .filter(|candidate| candidate.thread_id == env.thread_id)
1807            .cloned()
1808            .collect();
1809        if messages.is_empty() {
1810            messages.push(env.clone());
1811        }
1812        messages.sort_by_key(|message| message.date);
1813        messages
1814    }
1815
1816    fn default_thread_selected_index(&self) -> usize {
1817        self.viewed_thread_messages
1818            .iter()
1819            .rposition(|message| !message.flags.contains(MessageFlags::READ))
1820            .or_else(|| self.viewed_thread_messages.len().checked_sub(1))
1821            .unwrap_or(0)
1822    }
1823
1824    fn sync_focused_thread_envelope(&mut self) {
1825        self.close_attachment_panel();
1826        self.viewing_envelope = self.focused_thread_envelope().cloned();
1827        if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1828            self.schedule_preview_read(&viewing_envelope);
1829        } else {
1830            self.pending_preview_read = None;
1831        }
1832        self.message_scroll_offset = 0;
1833        self.ensure_current_body_state();
1834    }
1835
1836    fn schedule_preview_read(&mut self, envelope: &Envelope) {
1837        if envelope.flags.contains(MessageFlags::READ)
1838            || self.has_pending_set_read(&envelope.id, true)
1839        {
1840            self.pending_preview_read = None;
1841            return;
1842        }
1843
1844        if self
1845            .pending_preview_read
1846            .as_ref()
1847            .is_some_and(|pending| pending.message_id == envelope.id)
1848        {
1849            return;
1850        }
1851
1852        self.pending_preview_read = Some(PendingPreviewRead {
1853            message_id: envelope.id.clone(),
1854            due_at: Instant::now() + PREVIEW_MARK_READ_DELAY,
1855        });
1856    }
1857
1858    fn has_pending_set_read(&self, message_id: &MessageId, read: bool) -> bool {
1859        self.pending_mutation_queue.iter().any(|(request, _)| {
1860            matches!(
1861                request,
1862                Request::Mutation(MutationCommand::SetRead { message_ids, read: queued_read })
1863                    if *queued_read == read
1864                        && message_ids.len() == 1
1865                        && message_ids[0] == *message_id
1866            )
1867        })
1868    }
1869
1870    fn process_pending_preview_read(&mut self) {
1871        let Some(pending) = self.pending_preview_read.clone() else {
1872            return;
1873        };
1874        if Instant::now() < pending.due_at {
1875            return;
1876        }
1877        self.pending_preview_read = None;
1878
1879        let Some(envelope) = self
1880            .viewing_envelope
1881            .clone()
1882            .filter(|envelope| envelope.id == pending.message_id)
1883        else {
1884            return;
1885        };
1886
1887        if envelope.flags.contains(MessageFlags::READ)
1888            || self.has_pending_set_read(&envelope.id, true)
1889        {
1890            return;
1891        }
1892
1893        let mut flags = envelope.flags;
1894        flags.insert(MessageFlags::READ);
1895        self.apply_local_flags(&envelope.id, flags);
1896        self.queue_mutation(
1897            Request::Mutation(MutationCommand::SetRead {
1898                message_ids: vec![envelope.id.clone()],
1899                read: true,
1900            }),
1901            MutationEffect::StatusOnly("Marked message as read".into()),
1902            "Marking message as read...".into(),
1903        );
1904    }
1905
1906    pub fn next_background_timeout(&self, fallback: Duration) -> Duration {
1907        let Some(pending) = self.pending_preview_read.as_ref() else {
1908            return fallback;
1909        };
1910        fallback.min(
1911            pending
1912                .due_at
1913                .checked_duration_since(Instant::now())
1914                .unwrap_or(Duration::ZERO),
1915        )
1916    }
1917
1918    #[cfg(test)]
1919    pub fn expire_pending_preview_read_for_tests(&mut self) {
1920        if let Some(pending) = self.pending_preview_read.as_mut() {
1921            pending.due_at = Instant::now();
1922        }
1923    }
1924
1925    fn move_thread_focus_down(&mut self) {
1926        if self.thread_selected_index + 1 < self.viewed_thread_messages.len() {
1927            self.thread_selected_index += 1;
1928            self.sync_focused_thread_envelope();
1929        }
1930    }
1931
1932    fn move_thread_focus_up(&mut self) {
1933        if self.thread_selected_index > 0 {
1934            self.thread_selected_index -= 1;
1935            self.sync_focused_thread_envelope();
1936        }
1937    }
1938
1939    fn ensure_current_body_state(&mut self) {
1940        if let Some(env) = self.viewing_envelope.clone() {
1941            if !self.body_cache.contains_key(&env.id) {
1942                self.queue_body_fetch(env.id.clone());
1943            }
1944            self.body_view_state = self.resolve_body_view_state(&env);
1945        } else {
1946            self.body_view_state = BodyViewState::Empty { preview: None };
1947        }
1948    }
1949
1950    fn queue_body_fetch(&mut self, message_id: MessageId) {
1951        if self.body_cache.contains_key(&message_id)
1952            || self.in_flight_body_requests.contains(&message_id)
1953            || self.queued_body_fetches.contains(&message_id)
1954        {
1955            return;
1956        }
1957
1958        self.in_flight_body_requests.insert(message_id.clone());
1959        self.queued_body_fetches.push(message_id);
1960    }
1961
1962    fn queue_thread_fetch(&mut self, thread_id: mxr_core::ThreadId) {
1963        if self.pending_thread_fetch.as_ref() == Some(&thread_id)
1964            || self.in_flight_thread_fetch.as_ref() == Some(&thread_id)
1965        {
1966            return;
1967        }
1968        self.pending_thread_fetch = Some(thread_id);
1969    }
1970
1971    fn envelope_preview(envelope: &Envelope) -> Option<String> {
1972        let snippet = envelope.snippet.trim();
1973        if snippet.is_empty() {
1974            None
1975        } else {
1976            Some(envelope.snippet.clone())
1977        }
1978    }
1979
1980    fn render_body(raw: &str, source: BodySource, reader_mode: bool) -> String {
1981        if !reader_mode {
1982            return raw.to_string();
1983        }
1984
1985        let config = mxr_reader::ReaderConfig::default();
1986        match source {
1987            BodySource::Plain => mxr_reader::clean(Some(raw), None, &config).content,
1988            BodySource::Html => mxr_reader::clean(None, Some(raw), &config).content,
1989            BodySource::Snippet => raw.to_string(),
1990        }
1991    }
1992
1993    fn resolve_body_view_state(&self, envelope: &Envelope) -> BodyViewState {
1994        let preview = Self::envelope_preview(envelope);
1995
1996        if let Some(body) = self.body_cache.get(&envelope.id) {
1997            if let Some(raw) = body.text_plain.clone() {
1998                let rendered = Self::render_body(&raw, BodySource::Plain, self.reader_mode);
1999                return BodyViewState::Ready {
2000                    raw,
2001                    rendered,
2002                    source: BodySource::Plain,
2003                };
2004            }
2005
2006            if let Some(raw) = body.text_html.clone() {
2007                let rendered = Self::render_body(&raw, BodySource::Html, self.reader_mode);
2008                return BodyViewState::Ready {
2009                    raw,
2010                    rendered,
2011                    source: BodySource::Html,
2012                };
2013            }
2014
2015            return BodyViewState::Empty { preview };
2016        }
2017
2018        if self.in_flight_body_requests.contains(&envelope.id) {
2019            BodyViewState::Loading { preview }
2020        } else {
2021            BodyViewState::Empty { preview }
2022        }
2023    }
2024
2025    pub fn resolve_body_success(&mut self, body: MessageBody) {
2026        let message_id = body.message_id.clone();
2027        self.in_flight_body_requests.remove(&message_id);
2028        self.body_cache.insert(message_id.clone(), body);
2029
2030        if self.viewing_envelope.as_ref().map(|env| env.id.clone()) == Some(message_id) {
2031            self.ensure_current_body_state();
2032        }
2033    }
2034
2035    pub fn resolve_body_fetch_error(&mut self, message_id: &MessageId, message: String) {
2036        self.in_flight_body_requests.remove(message_id);
2037
2038        if let Some(env) = self
2039            .viewing_envelope
2040            .as_ref()
2041            .filter(|env| &env.id == message_id)
2042        {
2043            self.body_view_state = BodyViewState::Error {
2044                message,
2045                preview: Self::envelope_preview(env),
2046            };
2047        }
2048    }
2049
2050    pub fn current_viewing_body(&self) -> Option<&MessageBody> {
2051        self.viewing_envelope
2052            .as_ref()
2053            .and_then(|env| self.body_cache.get(&env.id))
2054    }
2055
2056    pub fn selected_attachment(&self) -> Option<&AttachmentMeta> {
2057        self.attachment_panel
2058            .attachments
2059            .get(self.attachment_panel.selected_index)
2060    }
2061
2062    pub fn open_attachment_panel(&mut self) {
2063        let Some(message_id) = self.viewing_envelope.as_ref().map(|env| env.id.clone()) else {
2064            self.status_message = Some("No message selected".into());
2065            return;
2066        };
2067        let Some(attachments) = self
2068            .current_viewing_body()
2069            .map(|body| body.attachments.clone())
2070        else {
2071            self.status_message = Some("No message body loaded".into());
2072            return;
2073        };
2074        if attachments.is_empty() {
2075            self.status_message = Some("No attachments".into());
2076            return;
2077        }
2078
2079        self.attachment_panel.visible = true;
2080        self.attachment_panel.message_id = Some(message_id);
2081        self.attachment_panel.attachments = attachments;
2082        self.attachment_panel.selected_index = 0;
2083        self.attachment_panel.status = None;
2084    }
2085
2086    pub fn open_url_modal(&mut self) {
2087        let body = self.current_viewing_body();
2088        let Some(body) = body else {
2089            self.status_message = Some("No message body loaded".into());
2090            return;
2091        };
2092        let text_plain = body.text_plain.as_deref();
2093        let text_html = body.text_html.as_deref();
2094        let urls = ui::url_modal::extract_urls(text_plain, text_html);
2095        if urls.is_empty() {
2096            self.status_message = Some("No links found".into());
2097            return;
2098        }
2099        self.url_modal = Some(ui::url_modal::UrlModalState::new(urls));
2100    }
2101
2102    pub fn close_attachment_panel(&mut self) {
2103        self.attachment_panel = AttachmentPanelState::default();
2104        self.pending_attachment_action = None;
2105    }
2106
2107    pub fn queue_attachment_action(&mut self, operation: AttachmentOperation) {
2108        let Some(message_id) = self.attachment_panel.message_id.clone() else {
2109            return;
2110        };
2111        let Some(attachment) = self.selected_attachment().cloned() else {
2112            return;
2113        };
2114
2115        self.attachment_panel.status = Some(match operation {
2116            AttachmentOperation::Open => format!("Opening {}...", attachment.filename),
2117            AttachmentOperation::Download => format!("Downloading {}...", attachment.filename),
2118        });
2119        self.pending_attachment_action = Some(PendingAttachmentAction {
2120            message_id,
2121            attachment_id: attachment.id,
2122            operation,
2123        });
2124    }
2125
2126    pub fn resolve_attachment_file(&mut self, file: &mxr_protocol::AttachmentFile) {
2127        let path = std::path::PathBuf::from(&file.path);
2128        for attachment in &mut self.attachment_panel.attachments {
2129            if attachment.id == file.attachment_id {
2130                attachment.local_path = Some(path.clone());
2131            }
2132        }
2133        for body in self.body_cache.values_mut() {
2134            for attachment in &mut body.attachments {
2135                if attachment.id == file.attachment_id {
2136                    attachment.local_path = Some(path.clone());
2137                }
2138            }
2139        }
2140    }
2141
2142    fn label_chips_for_envelope(&self, envelope: &Envelope) -> Vec<String> {
2143        envelope
2144            .label_provider_ids
2145            .iter()
2146            .filter_map(|provider_id| {
2147                self.labels
2148                    .iter()
2149                    .find(|label| &label.provider_id == provider_id)
2150                    .map(|label| crate::ui::sidebar::humanize_label(&label.name).to_string())
2151            })
2152            .collect()
2153    }
2154
2155    fn attachment_summaries_for_envelope(&self, envelope: &Envelope) -> Vec<AttachmentSummary> {
2156        self.body_cache
2157            .get(&envelope.id)
2158            .map(|body| {
2159                body.attachments
2160                    .iter()
2161                    .map(|attachment| AttachmentSummary {
2162                        filename: attachment.filename.clone(),
2163                        size_bytes: attachment.size_bytes,
2164                    })
2165                    .collect()
2166            })
2167            .unwrap_or_default()
2168    }
2169
2170    fn thread_message_blocks(&self) -> Vec<ui::message_view::ThreadMessageBlock> {
2171        self.viewed_thread_messages
2172            .iter()
2173            .map(|message| ui::message_view::ThreadMessageBlock {
2174                envelope: message.clone(),
2175                body_state: self.resolve_body_view_state(message),
2176                labels: self.label_chips_for_envelope(message),
2177                attachments: self.attachment_summaries_for_envelope(message),
2178                selected: self.viewing_envelope.as_ref().map(|env| env.id.clone())
2179                    == Some(message.id.clone()),
2180                has_unsubscribe: !matches!(message.unsubscribe, UnsubscribeMethod::None),
2181                signature_expanded: self.signature_expanded,
2182            })
2183            .collect()
2184    }
2185
2186    pub fn apply_local_label_refs(
2187        &mut self,
2188        message_ids: &[MessageId],
2189        add: &[String],
2190        remove: &[String],
2191    ) {
2192        let add_provider_ids = self.resolve_label_provider_ids(add);
2193        let remove_provider_ids = self.resolve_label_provider_ids(remove);
2194        for envelope in self
2195            .envelopes
2196            .iter_mut()
2197            .chain(self.all_envelopes.iter_mut())
2198            .chain(self.search_page.results.iter_mut())
2199            .chain(self.viewed_thread_messages.iter_mut())
2200        {
2201            if message_ids
2202                .iter()
2203                .any(|message_id| message_id == &envelope.id)
2204            {
2205                apply_provider_label_changes(
2206                    &mut envelope.label_provider_ids,
2207                    &add_provider_ids,
2208                    &remove_provider_ids,
2209                );
2210            }
2211        }
2212        if let Some(ref mut envelope) = self.viewing_envelope {
2213            if message_ids
2214                .iter()
2215                .any(|message_id| message_id == &envelope.id)
2216            {
2217                apply_provider_label_changes(
2218                    &mut envelope.label_provider_ids,
2219                    &add_provider_ids,
2220                    &remove_provider_ids,
2221                );
2222            }
2223        }
2224    }
2225
2226    pub fn apply_local_flags(&mut self, message_id: &MessageId, flags: MessageFlags) {
2227        for envelope in self
2228            .envelopes
2229            .iter_mut()
2230            .chain(self.all_envelopes.iter_mut())
2231            .chain(self.search_page.results.iter_mut())
2232            .chain(self.viewed_thread_messages.iter_mut())
2233        {
2234            if &envelope.id == message_id {
2235                envelope.flags = flags;
2236            }
2237        }
2238        if let Some(envelope) = self.viewing_envelope.as_mut() {
2239            if &envelope.id == message_id {
2240                envelope.flags = flags;
2241            }
2242        }
2243    }
2244
2245    pub fn apply_local_flags_many(&mut self, updates: &[(MessageId, MessageFlags)]) {
2246        for (message_id, flags) in updates {
2247            self.apply_local_flags(message_id, *flags);
2248        }
2249    }
2250
2251    fn apply_local_mutation_effect(&mut self, effect: &MutationEffect) {
2252        match effect {
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::RemoveFromList(_)
2268            | MutationEffect::RemoveFromListMany(_)
2269            | MutationEffect::RefreshList
2270            | MutationEffect::StatusOnly(_) => {}
2271        }
2272    }
2273
2274    fn queue_mutation(&mut self, request: Request, effect: MutationEffect, status_message: String) {
2275        self.pending_mutation_queue.push((request, effect));
2276        self.pending_mutation_count += 1;
2277        self.pending_mutation_status = Some(status_message.clone());
2278        self.status_message = Some(status_message);
2279    }
2280
2281    pub fn finish_pending_mutation(&mut self) {
2282        self.pending_mutation_count = self.pending_mutation_count.saturating_sub(1);
2283        if self.pending_mutation_count == 0 {
2284            self.pending_mutation_status = None;
2285        }
2286    }
2287
2288    pub fn show_mutation_failure(&mut self, error: &MxrError) {
2289        self.error_modal = Some(ErrorModalState {
2290            title: "Mutation Failed".into(),
2291            detail: format!(
2292                "Optimistic changes could not be applied.\nMailbox is refreshing to reconcile state.\n\n{error}"
2293            ),
2294        });
2295        self.status_message = Some(format!("Error: {error}"));
2296    }
2297
2298    pub fn refresh_mailbox_after_mutation_failure(&mut self) {
2299        self.pending_labels_refresh = true;
2300        self.pending_all_envelopes_refresh = true;
2301        self.pending_status_refresh = true;
2302        self.pending_subscriptions_refresh = true;
2303        if let Some(label_id) = self.active_label.clone() {
2304            self.pending_label_fetch = Some(label_id);
2305        }
2306    }
2307
2308    fn clear_message_view_state(&mut self) {
2309        self.pending_preview_read = None;
2310        self.viewing_envelope = None;
2311        self.viewed_thread = None;
2312        self.viewed_thread_messages.clear();
2313        self.thread_selected_index = 0;
2314        self.pending_thread_fetch = None;
2315        self.in_flight_thread_fetch = None;
2316        self.message_scroll_offset = 0;
2317        self.body_view_state = BodyViewState::Empty { preview: None };
2318    }
2319
2320    pub(crate) fn apply_removed_message_ids(&mut self, ids: &[MessageId]) {
2321        if ids.is_empty() {
2322            return;
2323        }
2324
2325        let viewing_removed = self
2326            .viewing_envelope
2327            .as_ref()
2328            .is_some_and(|envelope| ids.iter().any(|id| id == &envelope.id));
2329        let reader_was_open =
2330            self.layout_mode == LayoutMode::ThreePane && self.viewing_envelope.is_some();
2331
2332        self.envelopes
2333            .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2334        self.all_envelopes
2335            .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2336        self.search_page
2337            .results
2338            .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2339        self.viewed_thread_messages
2340            .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2341        self.selected_set
2342            .retain(|message_id| !ids.iter().any(|id| id == message_id));
2343
2344        self.selected_index = self
2345            .selected_index
2346            .min(self.mail_row_count().saturating_sub(1));
2347        self.search_page.selected_index = self
2348            .search_page
2349            .selected_index
2350            .min(self.search_row_count().saturating_sub(1));
2351
2352        if viewing_removed {
2353            self.clear_message_view_state();
2354
2355            if reader_was_open {
2356                match self.screen {
2357                    Screen::Search if self.search_row_count() > 0 => {
2358                        self.ensure_search_visible();
2359                        self.auto_preview_search();
2360                    }
2361                    Screen::Mailbox if self.mail_row_count() > 0 => {
2362                        self.ensure_visible();
2363                        self.auto_preview();
2364                    }
2365                    _ => {}
2366                }
2367            }
2368
2369            if self.viewing_envelope.is_none() && self.layout_mode == LayoutMode::ThreePane {
2370                self.layout_mode = LayoutMode::TwoPane;
2371                if self.active_pane == ActivePane::MessageView {
2372                    self.active_pane = ActivePane::MailList;
2373                }
2374            }
2375        } else {
2376            if self.screen == Screen::Mailbox && self.mail_row_count() > 0 {
2377                self.ensure_visible();
2378            } else if self.screen == Screen::Search && self.search_row_count() > 0 {
2379                self.ensure_search_visible();
2380            }
2381        }
2382    }
2383
2384    fn message_flags(&self, message_id: &MessageId) -> Option<MessageFlags> {
2385        self.envelopes
2386            .iter()
2387            .chain(self.all_envelopes.iter())
2388            .chain(self.search_page.results.iter())
2389            .chain(self.viewed_thread_messages.iter())
2390            .find(|envelope| &envelope.id == message_id)
2391            .map(|envelope| envelope.flags)
2392            .or_else(|| {
2393                self.viewing_envelope
2394                    .as_ref()
2395                    .filter(|envelope| &envelope.id == message_id)
2396                    .map(|envelope| envelope.flags)
2397            })
2398    }
2399
2400    fn flag_updates_for_ids<F>(
2401        &self,
2402        message_ids: &[MessageId],
2403        mut update: F,
2404    ) -> Vec<(MessageId, MessageFlags)>
2405    where
2406        F: FnMut(MessageFlags) -> MessageFlags,
2407    {
2408        message_ids
2409            .iter()
2410            .filter_map(|message_id| {
2411                self.message_flags(message_id)
2412                    .map(|flags| (message_id.clone(), update(flags)))
2413            })
2414            .collect()
2415    }
2416
2417    fn resolve_label_provider_ids(&self, refs: &[String]) -> Vec<String> {
2418        refs.iter()
2419            .filter_map(|label_ref| {
2420                self.labels
2421                    .iter()
2422                    .find(|label| label.provider_id == *label_ref || label.name == *label_ref)
2423                    .map(|label| label.provider_id.clone())
2424                    .or_else(|| Some(label_ref.clone()))
2425            })
2426            .collect()
2427    }
2428
2429    pub fn resolve_thread_success(&mut self, thread: Thread, mut messages: Vec<Envelope>) {
2430        let thread_id = thread.id.clone();
2431        self.in_flight_thread_fetch = None;
2432        messages.sort_by_key(|message| message.date);
2433
2434        if self
2435            .viewing_envelope
2436            .as_ref()
2437            .map(|env| env.thread_id.clone())
2438            == Some(thread_id)
2439        {
2440            let focused_message_id = self.focused_thread_envelope().map(|env| env.id.clone());
2441            for message in &messages {
2442                self.queue_body_fetch(message.id.clone());
2443            }
2444            self.viewed_thread = Some(thread);
2445            self.viewed_thread_messages = messages;
2446            self.thread_selected_index = focused_message_id
2447                .and_then(|message_id| {
2448                    self.viewed_thread_messages
2449                        .iter()
2450                        .position(|message| message.id == message_id)
2451                })
2452                .unwrap_or_else(|| self.default_thread_selected_index());
2453            self.sync_focused_thread_envelope();
2454        }
2455    }
2456
2457    pub fn resolve_thread_fetch_error(&mut self, thread_id: &mxr_core::ThreadId) {
2458        if self.in_flight_thread_fetch.as_ref() == Some(thread_id) {
2459            self.in_flight_thread_fetch = None;
2460        }
2461    }
2462
2463    /// Get IDs to mutate: selected_set if non-empty, else context_envelope.
2464    fn mutation_target_ids(&self) -> Vec<MessageId> {
2465        if !self.selected_set.is_empty() {
2466            self.selected_set.iter().cloned().collect()
2467        } else if let Some(env) = self.context_envelope() {
2468            vec![env.id.clone()]
2469        } else {
2470            vec![]
2471        }
2472    }
2473
2474    fn clear_selection(&mut self) {
2475        self.selected_set.clear();
2476        self.visual_mode = false;
2477        self.visual_anchor = None;
2478    }
2479
2480    fn queue_or_confirm_bulk_action(
2481        &mut self,
2482        title: impl Into<String>,
2483        detail: impl Into<String>,
2484        request: Request,
2485        effect: MutationEffect,
2486        optimistic_effect: Option<MutationEffect>,
2487        status_message: String,
2488        count: usize,
2489    ) {
2490        if count > 1 {
2491            self.pending_bulk_confirm = Some(PendingBulkConfirm {
2492                title: title.into(),
2493                detail: detail.into(),
2494                request,
2495                effect,
2496                optimistic_effect,
2497                status_message,
2498            });
2499        } else {
2500            if let Some(effect) = optimistic_effect.as_ref() {
2501                self.apply_local_mutation_effect(effect);
2502            }
2503            self.queue_mutation(request, effect, status_message);
2504            self.clear_selection();
2505        }
2506    }
2507
2508    /// Update visual selection range when moving in visual mode.
2509    fn update_visual_selection(&mut self) {
2510        if self.visual_mode {
2511            if let Some(anchor) = self.visual_anchor {
2512                let (cursor, source) = if self.screen == Screen::Search {
2513                    (self.search_page.selected_index, &self.search_page.results)
2514                } else {
2515                    (self.selected_index, &self.envelopes)
2516                };
2517                let start = anchor.min(cursor);
2518                let end = anchor.max(cursor);
2519                self.selected_set.clear();
2520                for env in source.iter().skip(start).take(end - start + 1) {
2521                    self.selected_set.insert(env.id.clone());
2522                }
2523            }
2524        }
2525    }
2526
2527    /// Ensure selected_index is visible within the scroll viewport.
2528    fn ensure_visible(&mut self) {
2529        let h = self.visible_height.max(1);
2530        if self.selected_index < self.scroll_offset {
2531            self.scroll_offset = self.selected_index;
2532        } else if self.selected_index >= self.scroll_offset + h {
2533            self.scroll_offset = self.selected_index + 1 - h;
2534        }
2535        // Prefetch bodies for messages near the cursor
2536        self.queue_body_window();
2537    }
2538
2539    pub fn set_subscriptions(&mut self, subscriptions: Vec<SubscriptionSummary>) {
2540        let selected_id = self
2541            .selected_subscription_entry()
2542            .map(|entry| entry.summary.latest_message_id.clone());
2543        self.subscriptions_page.entries = subscriptions
2544            .into_iter()
2545            .map(|summary| SubscriptionEntry {
2546                envelope: subscription_summary_to_envelope(&summary),
2547                summary,
2548            })
2549            .collect();
2550
2551        if self.subscriptions_page.entries.is_empty() {
2552            if self.mailbox_view == MailboxView::Subscriptions {
2553                self.selected_index = 0;
2554                self.scroll_offset = 0;
2555                self.viewing_envelope = None;
2556                self.viewed_thread = None;
2557                self.viewed_thread_messages.clear();
2558                self.body_view_state = BodyViewState::Empty { preview: None };
2559            }
2560            return;
2561        }
2562
2563        if let Some(selected_id) = selected_id {
2564            if let Some(position) = self
2565                .subscriptions_page
2566                .entries
2567                .iter()
2568                .position(|entry| entry.summary.latest_message_id == selected_id)
2569            {
2570                self.selected_index = position;
2571            } else {
2572                self.selected_index = self
2573                    .selected_index
2574                    .min(self.subscriptions_page.entries.len().saturating_sub(1));
2575            }
2576        } else {
2577            self.selected_index = self
2578                .selected_index
2579                .min(self.subscriptions_page.entries.len().saturating_sub(1));
2580        }
2581
2582        if self.mailbox_view == MailboxView::Subscriptions {
2583            self.ensure_visible();
2584            self.auto_preview();
2585        }
2586    }
2587}
2588
2589fn apply_provider_label_changes(
2590    label_provider_ids: &mut Vec<String>,
2591    add_provider_ids: &[String],
2592    remove_provider_ids: &[String],
2593) {
2594    label_provider_ids.retain(|provider_id| {
2595        !remove_provider_ids
2596            .iter()
2597            .any(|remove| remove == provider_id)
2598    });
2599    for provider_id in add_provider_ids {
2600        if !label_provider_ids
2601            .iter()
2602            .any(|existing| existing == provider_id)
2603        {
2604            label_provider_ids.push(provider_id.clone());
2605        }
2606    }
2607}
2608
2609fn unsubscribe_method_label(method: &UnsubscribeMethod) -> &'static str {
2610    match method {
2611        UnsubscribeMethod::OneClick { .. } => "one-click",
2612        UnsubscribeMethod::Mailto { .. } => "mailto",
2613        UnsubscribeMethod::HttpLink { .. } => "browser link",
2614        UnsubscribeMethod::BodyLink { .. } => "body link",
2615        UnsubscribeMethod::None => "none",
2616    }
2617}
2618
2619fn remove_from_list_effect(ids: &[MessageId]) -> MutationEffect {
2620    if ids.len() == 1 {
2621        MutationEffect::RemoveFromList(ids[0].clone())
2622    } else {
2623        MutationEffect::RemoveFromListMany(ids.to_vec())
2624    }
2625}
2626
2627fn pluralize_messages(count: usize) -> &'static str {
2628    if count == 1 {
2629        "message"
2630    } else {
2631        "messages"
2632    }
2633}
2634
2635fn bulk_message_detail(verb: &str, count: usize) -> String {
2636    format!(
2637        "You are about to {verb} these {count} {}.",
2638        pluralize_messages(count)
2639    )
2640}
2641
2642fn subscription_summary_to_envelope(summary: &SubscriptionSummary) -> Envelope {
2643    Envelope {
2644        id: summary.latest_message_id.clone(),
2645        account_id: summary.account_id.clone(),
2646        provider_id: summary.latest_provider_id.clone(),
2647        thread_id: summary.latest_thread_id.clone(),
2648        message_id_header: None,
2649        in_reply_to: None,
2650        references: vec![],
2651        from: Address {
2652            name: summary.sender_name.clone(),
2653            email: summary.sender_email.clone(),
2654        },
2655        to: vec![],
2656        cc: vec![],
2657        bcc: vec![],
2658        subject: summary.latest_subject.clone(),
2659        date: summary.latest_date,
2660        flags: summary.latest_flags,
2661        snippet: summary.latest_snippet.clone(),
2662        has_attachments: summary.latest_has_attachments,
2663        size_bytes: summary.latest_size_bytes,
2664        unsubscribe: summary.unsubscribe.clone(),
2665        label_provider_ids: vec![],
2666    }
2667}
2668
2669fn account_summary_to_config(
2670    account: &mxr_protocol::AccountSummaryData,
2671) -> Option<mxr_protocol::AccountConfigData> {
2672    Some(mxr_protocol::AccountConfigData {
2673        key: account.key.clone()?,
2674        name: account.name.clone(),
2675        email: account.email.clone(),
2676        sync: account.sync.clone(),
2677        send: account.send.clone(),
2678        is_default: account.is_default,
2679    })
2680}
2681
2682fn account_form_from_config(account: mxr_protocol::AccountConfigData) -> AccountFormState {
2683    let mut form = AccountFormState {
2684        visible: true,
2685        key: account.key,
2686        name: account.name,
2687        email: account.email,
2688        ..AccountFormState::default()
2689    };
2690
2691    if let Some(sync) = account.sync {
2692        match sync {
2693            mxr_protocol::AccountSyncConfigData::Gmail {
2694                credential_source,
2695                client_id,
2696                client_secret,
2697                token_ref,
2698            } => {
2699                form.mode = AccountFormMode::Gmail;
2700                form.gmail_credential_source = credential_source;
2701                form.gmail_client_id = client_id;
2702                form.gmail_client_secret = client_secret.unwrap_or_default();
2703                form.gmail_token_ref = token_ref;
2704            }
2705            mxr_protocol::AccountSyncConfigData::Imap {
2706                host,
2707                port,
2708                username,
2709                password_ref,
2710                ..
2711            } => {
2712                form.mode = AccountFormMode::ImapSmtp;
2713                form.imap_host = host;
2714                form.imap_port = port.to_string();
2715                form.imap_username = username;
2716                form.imap_password_ref = password_ref;
2717            }
2718        }
2719    } else {
2720        form.mode = AccountFormMode::SmtpOnly;
2721    }
2722
2723    match account.send {
2724        Some(mxr_protocol::AccountSendConfigData::Smtp {
2725            host,
2726            port,
2727            username,
2728            password_ref,
2729            ..
2730        }) => {
2731            form.smtp_host = host;
2732            form.smtp_port = port.to_string();
2733            form.smtp_username = username;
2734            form.smtp_password_ref = password_ref;
2735        }
2736        Some(mxr_protocol::AccountSendConfigData::Gmail) => {
2737            if form.gmail_token_ref.is_empty() {
2738                form.gmail_token_ref = format!("mxr/{}-gmail", form.key);
2739            }
2740        }
2741        None => {}
2742    }
2743
2744    form
2745}
2746
2747fn account_form_field_value(form: &AccountFormState) -> Option<&str> {
2748    match (form.mode, form.active_field) {
2749        (_, 0) => None,
2750        (_, 1) => Some(form.key.as_str()),
2751        (_, 2) => Some(form.name.as_str()),
2752        (_, 3) => Some(form.email.as_str()),
2753        (AccountFormMode::Gmail, 4) => None,
2754        (AccountFormMode::Gmail, 5)
2755            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2756        {
2757            Some(form.gmail_client_id.as_str())
2758        }
2759        (AccountFormMode::Gmail, 6)
2760            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2761        {
2762            Some(form.gmail_client_secret.as_str())
2763        }
2764        (AccountFormMode::Gmail, 5 | 6) => None,
2765        (AccountFormMode::Gmail, 7) => None,
2766        (AccountFormMode::ImapSmtp, 4) => Some(form.imap_host.as_str()),
2767        (AccountFormMode::ImapSmtp, 5) => Some(form.imap_port.as_str()),
2768        (AccountFormMode::ImapSmtp, 6) => Some(form.imap_username.as_str()),
2769        (AccountFormMode::ImapSmtp, 7) => Some(form.imap_password_ref.as_str()),
2770        (AccountFormMode::ImapSmtp, 8) => Some(form.imap_password.as_str()),
2771        (AccountFormMode::ImapSmtp, 9) => Some(form.smtp_host.as_str()),
2772        (AccountFormMode::ImapSmtp, 10) => Some(form.smtp_port.as_str()),
2773        (AccountFormMode::ImapSmtp, 11) => Some(form.smtp_username.as_str()),
2774        (AccountFormMode::ImapSmtp, 12) => Some(form.smtp_password_ref.as_str()),
2775        (AccountFormMode::ImapSmtp, 13) => Some(form.smtp_password.as_str()),
2776        (AccountFormMode::SmtpOnly, 4) => Some(form.smtp_host.as_str()),
2777        (AccountFormMode::SmtpOnly, 5) => Some(form.smtp_port.as_str()),
2778        (AccountFormMode::SmtpOnly, 6) => Some(form.smtp_username.as_str()),
2779        (AccountFormMode::SmtpOnly, 7) => Some(form.smtp_password_ref.as_str()),
2780        (AccountFormMode::SmtpOnly, 8) => Some(form.smtp_password.as_str()),
2781        _ => None,
2782    }
2783}
2784
2785fn account_form_field_is_editable(form: &AccountFormState) -> bool {
2786    account_form_field_value(form).is_some()
2787}
2788
2789fn with_account_form_field_mut<F>(form: &mut AccountFormState, mut update: F)
2790where
2791    F: FnMut(&mut String),
2792{
2793    let field = match (form.mode, form.active_field) {
2794        (_, 1) => &mut form.key,
2795        (_, 2) => &mut form.name,
2796        (_, 3) => &mut form.email,
2797        (AccountFormMode::Gmail, 5)
2798            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2799        {
2800            &mut form.gmail_client_id
2801        }
2802        (AccountFormMode::Gmail, 6)
2803            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2804        {
2805            &mut form.gmail_client_secret
2806        }
2807        (AccountFormMode::ImapSmtp, 4) => &mut form.imap_host,
2808        (AccountFormMode::ImapSmtp, 5) => &mut form.imap_port,
2809        (AccountFormMode::ImapSmtp, 6) => &mut form.imap_username,
2810        (AccountFormMode::ImapSmtp, 7) => &mut form.imap_password_ref,
2811        (AccountFormMode::ImapSmtp, 8) => &mut form.imap_password,
2812        (AccountFormMode::ImapSmtp, 9) => &mut form.smtp_host,
2813        (AccountFormMode::ImapSmtp, 10) => &mut form.smtp_port,
2814        (AccountFormMode::ImapSmtp, 11) => &mut form.smtp_username,
2815        (AccountFormMode::ImapSmtp, 12) => &mut form.smtp_password_ref,
2816        (AccountFormMode::ImapSmtp, 13) => &mut form.smtp_password,
2817        (AccountFormMode::SmtpOnly, 4) => &mut form.smtp_host,
2818        (AccountFormMode::SmtpOnly, 5) => &mut form.smtp_port,
2819        (AccountFormMode::SmtpOnly, 6) => &mut form.smtp_username,
2820        (AccountFormMode::SmtpOnly, 7) => &mut form.smtp_password_ref,
2821        (AccountFormMode::SmtpOnly, 8) => &mut form.smtp_password,
2822        _ => return,
2823    };
2824    update(field);
2825}
2826
2827fn insert_account_form_char(form: &mut AccountFormState, c: char) {
2828    let cursor = form.field_cursor;
2829    with_account_form_field_mut(form, |value| {
2830        let insert_at = char_to_byte_index(value, cursor);
2831        value.insert(insert_at, c);
2832    });
2833    form.field_cursor = form.field_cursor.saturating_add(1);
2834}
2835
2836fn delete_account_form_char(form: &mut AccountFormState, backspace: bool) {
2837    let cursor = form.field_cursor;
2838    with_account_form_field_mut(form, |value| {
2839        if backspace {
2840            if cursor == 0 {
2841                return;
2842            }
2843            let start = char_to_byte_index(value, cursor - 1);
2844            let end = char_to_byte_index(value, cursor);
2845            value.replace_range(start..end, "");
2846        } else {
2847            let len = value.chars().count();
2848            if cursor >= len {
2849                return;
2850            }
2851            let start = char_to_byte_index(value, cursor);
2852            let end = char_to_byte_index(value, cursor + 1);
2853            value.replace_range(start..end, "");
2854        }
2855    });
2856    if backspace {
2857        form.field_cursor = form.field_cursor.saturating_sub(1);
2858    }
2859}
2860
2861fn char_to_byte_index(value: &str, char_index: usize) -> usize {
2862    value
2863        .char_indices()
2864        .nth(char_index)
2865        .map(|(index, _)| index)
2866        .unwrap_or(value.len())
2867}
2868
2869fn next_gmail_credential_source(
2870    current: mxr_protocol::GmailCredentialSourceData,
2871    forward: bool,
2872) -> mxr_protocol::GmailCredentialSourceData {
2873    match (current, forward) {
2874        (mxr_protocol::GmailCredentialSourceData::Bundled, true) => {
2875            mxr_protocol::GmailCredentialSourceData::Custom
2876        }
2877        (mxr_protocol::GmailCredentialSourceData::Custom, true) => {
2878            mxr_protocol::GmailCredentialSourceData::Bundled
2879        }
2880        (mxr_protocol::GmailCredentialSourceData::Bundled, false) => {
2881            mxr_protocol::GmailCredentialSourceData::Custom
2882        }
2883        (mxr_protocol::GmailCredentialSourceData::Custom, false) => {
2884            mxr_protocol::GmailCredentialSourceData::Bundled
2885        }
2886    }
2887}
2888
2889pub fn snooze_presets() -> [SnoozePreset; 4] {
2890    [
2891        SnoozePreset::TomorrowMorning,
2892        SnoozePreset::Tonight,
2893        SnoozePreset::Weekend,
2894        SnoozePreset::NextMonday,
2895    ]
2896}
2897
2898pub fn resolve_snooze_preset(
2899    preset: SnoozePreset,
2900    config: &mxr_config::SnoozeConfig,
2901) -> chrono::DateTime<chrono::Utc> {
2902    use chrono::{Datelike, Duration, Local, NaiveTime, Weekday};
2903
2904    let now = Local::now();
2905    match preset {
2906        SnoozePreset::TomorrowMorning => {
2907            let tomorrow = now.date_naive() + Duration::days(1);
2908            let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2909            tomorrow
2910                .and_time(time)
2911                .and_local_timezone(now.timezone())
2912                .single()
2913                .unwrap()
2914                .with_timezone(&chrono::Utc)
2915        }
2916        SnoozePreset::Tonight => {
2917            let today = now.date_naive();
2918            let time = NaiveTime::from_hms_opt(config.evening_hour as u32, 0, 0).unwrap();
2919            let tonight = today
2920                .and_time(time)
2921                .and_local_timezone(now.timezone())
2922                .single()
2923                .unwrap()
2924                .with_timezone(&chrono::Utc);
2925            if tonight <= chrono::Utc::now() {
2926                tonight + Duration::days(1)
2927            } else {
2928                tonight
2929            }
2930        }
2931        SnoozePreset::Weekend => {
2932            let target_day = match config.weekend_day.as_str() {
2933                "sunday" => Weekday::Sun,
2934                _ => Weekday::Sat,
2935            };
2936            let days_until = (target_day.num_days_from_monday() as i64
2937                - now.weekday().num_days_from_monday() as i64
2938                + 7)
2939                % 7;
2940            let days = if days_until == 0 { 7 } else { days_until };
2941            let weekend = now.date_naive() + Duration::days(days);
2942            let time = NaiveTime::from_hms_opt(config.weekend_hour as u32, 0, 0).unwrap();
2943            weekend
2944                .and_time(time)
2945                .and_local_timezone(now.timezone())
2946                .single()
2947                .unwrap()
2948                .with_timezone(&chrono::Utc)
2949        }
2950        SnoozePreset::NextMonday => {
2951            let days_until_monday = (Weekday::Mon.num_days_from_monday() as i64
2952                - now.weekday().num_days_from_monday() as i64
2953                + 7)
2954                % 7;
2955            let days = if days_until_monday == 0 {
2956                7
2957            } else {
2958                days_until_monday
2959            };
2960            let monday = now.date_naive() + Duration::days(days);
2961            let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2962            monday
2963                .and_time(time)
2964                .and_local_timezone(now.timezone())
2965                .single()
2966                .unwrap()
2967                .with_timezone(&chrono::Utc)
2968        }
2969    }
2970}
2971
2972#[cfg(test)]
2973mod tests {
2974    use super::*;
2975    use chrono::TimeZone;
2976
2977    fn test_envelope(
2978        thread_id: mxr_core::ThreadId,
2979        subject: &str,
2980        date: chrono::DateTime<chrono::Utc>,
2981    ) -> Envelope {
2982        Envelope {
2983            id: MessageId::new(),
2984            account_id: AccountId::new(),
2985            provider_id: subject.to_string(),
2986            thread_id,
2987            message_id_header: None,
2988            in_reply_to: None,
2989            references: vec![],
2990            from: Address {
2991                name: Some("Alice".to_string()),
2992                email: "alice@example.com".to_string(),
2993            },
2994            to: vec![],
2995            cc: vec![],
2996            bcc: vec![],
2997            subject: subject.to_string(),
2998            date,
2999            flags: MessageFlags::empty(),
3000            snippet: String::new(),
3001            has_attachments: false,
3002            size_bytes: 0,
3003            unsubscribe: UnsubscribeMethod::None,
3004            label_provider_ids: vec![],
3005        }
3006    }
3007
3008    #[test]
3009    fn build_mail_list_rows_ignores_impossible_future_thread_dates() {
3010        let thread_id = mxr_core::ThreadId::new();
3011        let poisoned = test_envelope(
3012            thread_id.clone(),
3013            "Poisoned future",
3014            chrono::Utc
3015                .timestamp_opt(236_816_444_325, 0)
3016                .single()
3017                .unwrap(),
3018        );
3019        let recent = test_envelope(thread_id, "Real recent", chrono::Utc::now());
3020
3021        let rows = App::build_mail_list_rows(&[poisoned, recent.clone()], MailListMode::Threads);
3022
3023        assert_eq!(rows.len(), 1);
3024        assert_eq!(rows[0].representative.subject, recent.subject);
3025    }
3026}