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