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, SearchMode)>,
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)) => {
1016                Some(Action::SelectSavedSearch(search.query, search.search_mode))
1017            }
1018            None => None,
1019        }
1020    }
1021
1022    fn sync_sidebar_section(&mut self) {
1023        self.sidebar_section = match self.selected_sidebar_item() {
1024            Some(SidebarItem::SavedSearch(_)) => SidebarSection::SavedSearches,
1025            _ => SidebarSection::Labels,
1026        };
1027    }
1028
1029    fn current_sidebar_group(&self) -> SidebarGroup {
1030        match self.selected_sidebar_item() {
1031            Some(SidebarItem::SavedSearch(_)) => SidebarGroup::SavedSearches,
1032            Some(SidebarItem::Label(label)) if label.kind == LabelKind::System => {
1033                SidebarGroup::SystemLabels
1034            }
1035            Some(SidebarItem::Label(_)) => SidebarGroup::UserLabels,
1036            Some(SidebarItem::AllMail) | Some(SidebarItem::Subscriptions) | None => {
1037                SidebarGroup::SystemLabels
1038            }
1039        }
1040    }
1041
1042    fn collapse_current_sidebar_section(&mut self) {
1043        match self.current_sidebar_group() {
1044            SidebarGroup::SystemLabels => self.sidebar_system_expanded = false,
1045            SidebarGroup::UserLabels => self.sidebar_user_expanded = false,
1046            SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = false,
1047        }
1048        self.sidebar_selected = self
1049            .sidebar_selected
1050            .min(self.sidebar_items().len().saturating_sub(1));
1051        self.sync_sidebar_section();
1052    }
1053
1054    fn expand_current_sidebar_section(&mut self) {
1055        match self.current_sidebar_group() {
1056            SidebarGroup::SystemLabels => self.sidebar_system_expanded = true,
1057            SidebarGroup::UserLabels => self.sidebar_user_expanded = true,
1058            SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = true,
1059        }
1060        self.sidebar_selected = self
1061            .sidebar_selected
1062            .min(self.sidebar_items().len().saturating_sub(1));
1063        self.sync_sidebar_section();
1064    }
1065
1066    /// Live filter: instant client-side prefix matching on subject/from/snippet,
1067    /// plus async Tantivy search for full-text body matches.
1068    fn trigger_live_search(&mut self) {
1069        let query = self.search_bar.query.to_lowercase();
1070        if query.is_empty() {
1071            self.envelopes = self.all_mail_envelopes();
1072            self.search_active = false;
1073        } else {
1074            let query_words: Vec<&str> = query.split_whitespace().collect();
1075            // Instant client-side filter: every query word must prefix-match
1076            // some word in subject, from, or snippet
1077            self.envelopes = self
1078                .all_envelopes
1079                .iter()
1080                .filter(|e| !e.flags.contains(MessageFlags::TRASH))
1081                .filter(|e| {
1082                    let haystack = format!(
1083                        "{} {} {} {}",
1084                        e.subject,
1085                        e.from.email,
1086                        e.from.name.as_deref().unwrap_or(""),
1087                        e.snippet
1088                    )
1089                    .to_lowercase();
1090                    query_words.iter().all(|qw| {
1091                        haystack.split_whitespace().any(|hw| hw.starts_with(qw))
1092                            || haystack.contains(qw)
1093                    })
1094                })
1095                .cloned()
1096                .collect();
1097            self.search_active = true;
1098            // Also fire async Tantivy search to catch body matches
1099            self.pending_search = Some((self.search_bar.query.clone(), self.search_bar.mode));
1100        }
1101        self.selected_index = 0;
1102        self.scroll_offset = 0;
1103    }
1104
1105    /// Compute the mail list title based on active filter/search.
1106    pub fn mail_list_title(&self) -> String {
1107        if self.mailbox_view == MailboxView::Subscriptions {
1108            return format!("Subscriptions ({})", self.subscriptions_page.entries.len());
1109        }
1110
1111        let list_name = match self.mail_list_mode {
1112            MailListMode::Threads => "Threads",
1113            MailListMode::Messages => "Messages",
1114        };
1115        let list_count = self.mail_row_count();
1116        if self.search_active {
1117            format!("Search: {} ({list_count})", self.search_bar.query)
1118        } else if let Some(label_id) = self
1119            .pending_active_label
1120            .as_ref()
1121            .or(self.active_label.as_ref())
1122        {
1123            if let Some(label) = self.labels.iter().find(|l| &l.id == label_id) {
1124                let name = crate::ui::sidebar::humanize_label(&label.name);
1125                format!("{name} {list_name} ({list_count})")
1126            } else {
1127                format!("{list_name} ({list_count})")
1128            }
1129        } else {
1130            format!("All Mail {list_name} ({list_count})")
1131        }
1132    }
1133
1134    fn all_mail_envelopes(&self) -> Vec<Envelope> {
1135        self.all_envelopes
1136            .iter()
1137            .filter(|envelope| !envelope.flags.contains(MessageFlags::TRASH))
1138            .cloned()
1139            .collect()
1140    }
1141
1142    pub fn resolve_desired_system_mailbox(&mut self) {
1143        let Some(target) = self.desired_system_mailbox.as_deref() else {
1144            return;
1145        };
1146        if self.pending_active_label.is_some() || self.active_label.is_some() {
1147            return;
1148        }
1149        if let Some(label_id) = self
1150            .labels
1151            .iter()
1152            .find(|label| label.name.eq_ignore_ascii_case(target))
1153            .map(|label| label.id.clone())
1154        {
1155            self.apply(Action::SelectLabel(label_id));
1156        }
1157    }
1158
1159    /// In ThreePane mode, auto-load the preview for the currently selected envelope.
1160    fn auto_preview(&mut self) {
1161        if self.mailbox_view == MailboxView::Subscriptions {
1162            if let Some(entry) = self.selected_subscription_entry().cloned() {
1163                if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&entry.envelope.id) {
1164                    self.open_envelope(entry.envelope);
1165                }
1166            } else {
1167                self.viewing_envelope = None;
1168                self.viewed_thread = None;
1169                self.viewed_thread_messages.clear();
1170                self.body_view_state = BodyViewState::Empty { preview: None };
1171            }
1172            return;
1173        }
1174
1175        if self.layout_mode == LayoutMode::ThreePane {
1176            if let Some(row) = self.selected_mail_row() {
1177                if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&row.representative.id) {
1178                    self.open_envelope(row.representative);
1179                }
1180            }
1181        }
1182    }
1183
1184    pub fn auto_preview_search(&mut self) {
1185        if let Some(env) = self.selected_search_envelope().cloned() {
1186            if self
1187                .viewing_envelope
1188                .as_ref()
1189                .map(|current| current.id.clone())
1190                != Some(env.id.clone())
1191            {
1192                self.open_envelope(env);
1193            }
1194        }
1195    }
1196
1197    fn ensure_search_visible(&mut self) {
1198        let h = self.visible_height.max(1);
1199        if self.search_page.selected_index < self.search_page.scroll_offset {
1200            self.search_page.scroll_offset = self.search_page.selected_index;
1201        } else if self.search_page.selected_index >= self.search_page.scroll_offset + h {
1202            self.search_page.scroll_offset = self.search_page.selected_index + 1 - h;
1203        }
1204    }
1205
1206    /// Queue body prefetch for messages around the current cursor position.
1207    /// Only fetches bodies not already in cache.
1208    pub fn queue_body_window(&mut self) {
1209        const BUFFER: usize = 50;
1210        let source_envelopes: Vec<Envelope> = if self.mailbox_view == MailboxView::Subscriptions {
1211            self.subscriptions_page
1212                .entries
1213                .iter()
1214                .map(|entry| entry.envelope.clone())
1215                .collect()
1216        } else {
1217            self.envelopes.clone()
1218        };
1219        let len = source_envelopes.len();
1220        if len == 0 {
1221            return;
1222        }
1223        let start = self.selected_index.saturating_sub(BUFFER / 2);
1224        let end = (self.selected_index + BUFFER / 2).min(len);
1225        let ids: Vec<MessageId> = source_envelopes[start..end]
1226            .iter()
1227            .map(|e| e.id.clone())
1228            .collect();
1229        for id in ids {
1230            self.queue_body_fetch(id);
1231        }
1232    }
1233
1234    fn open_envelope(&mut self, env: Envelope) {
1235        self.close_attachment_panel();
1236        self.signature_expanded = false;
1237        self.viewed_thread = None;
1238        self.viewed_thread_messages = self.optimistic_thread_messages(&env);
1239        self.thread_selected_index = self.default_thread_selected_index();
1240        self.viewing_envelope = self.focused_thread_envelope().cloned();
1241        if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1242            self.mark_envelope_read_on_open(&viewing_envelope);
1243        }
1244        for message in self.viewed_thread_messages.clone() {
1245            self.queue_body_fetch(message.id);
1246        }
1247        self.queue_thread_fetch(env.thread_id.clone());
1248        self.message_scroll_offset = 0;
1249        self.ensure_current_body_state();
1250    }
1251
1252    fn optimistic_thread_messages(&self, env: &Envelope) -> Vec<Envelope> {
1253        let mut messages: Vec<Envelope> = self
1254            .all_envelopes
1255            .iter()
1256            .filter(|candidate| candidate.thread_id == env.thread_id)
1257            .cloned()
1258            .collect();
1259        if messages.is_empty() {
1260            messages.push(env.clone());
1261        }
1262        messages.sort_by_key(|message| message.date);
1263        messages
1264    }
1265
1266    fn default_thread_selected_index(&self) -> usize {
1267        self.viewed_thread_messages
1268            .iter()
1269            .rposition(|message| !message.flags.contains(MessageFlags::READ))
1270            .or_else(|| self.viewed_thread_messages.len().checked_sub(1))
1271            .unwrap_or(0)
1272    }
1273
1274    fn sync_focused_thread_envelope(&mut self) {
1275        self.close_attachment_panel();
1276        self.viewing_envelope = self.focused_thread_envelope().cloned();
1277        if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1278            self.mark_envelope_read_on_open(&viewing_envelope);
1279        }
1280        self.message_scroll_offset = 0;
1281        self.ensure_current_body_state();
1282    }
1283
1284    fn mark_envelope_read_on_open(&mut self, envelope: &Envelope) {
1285        if envelope.flags.contains(MessageFlags::READ)
1286            || self.has_pending_set_read(&envelope.id, true)
1287        {
1288            return;
1289        }
1290
1291        let mut flags = envelope.flags;
1292        flags.insert(MessageFlags::READ);
1293        self.apply_local_flags(&envelope.id, flags);
1294        self.pending_mutation_queue.push((
1295            Request::Mutation(MutationCommand::SetRead {
1296                message_ids: vec![envelope.id.clone()],
1297                read: true,
1298            }),
1299            MutationEffect::UpdateFlags {
1300                message_id: envelope.id.clone(),
1301                flags,
1302            },
1303        ));
1304    }
1305
1306    fn has_pending_set_read(&self, message_id: &MessageId, read: bool) -> bool {
1307        self.pending_mutation_queue.iter().any(|(request, _)| {
1308            matches!(
1309                request,
1310                Request::Mutation(MutationCommand::SetRead { message_ids, read: queued_read })
1311                    if *queued_read == read
1312                        && message_ids.len() == 1
1313                        && message_ids[0] == *message_id
1314            )
1315        })
1316    }
1317
1318    fn move_thread_focus_down(&mut self) {
1319        if self.thread_selected_index + 1 < self.viewed_thread_messages.len() {
1320            self.thread_selected_index += 1;
1321            self.sync_focused_thread_envelope();
1322        }
1323    }
1324
1325    fn move_thread_focus_up(&mut self) {
1326        if self.thread_selected_index > 0 {
1327            self.thread_selected_index -= 1;
1328            self.sync_focused_thread_envelope();
1329        }
1330    }
1331
1332    fn ensure_current_body_state(&mut self) {
1333        if let Some(env) = self.viewing_envelope.clone() {
1334            if !self.body_cache.contains_key(&env.id) {
1335                self.queue_body_fetch(env.id.clone());
1336            }
1337            self.body_view_state = self.resolve_body_view_state(&env);
1338        } else {
1339            self.body_view_state = BodyViewState::Empty { preview: None };
1340        }
1341    }
1342
1343    fn queue_body_fetch(&mut self, message_id: MessageId) {
1344        if self.body_cache.contains_key(&message_id)
1345            || self.in_flight_body_requests.contains(&message_id)
1346            || self.queued_body_fetches.contains(&message_id)
1347        {
1348            return;
1349        }
1350
1351        self.in_flight_body_requests.insert(message_id.clone());
1352        self.queued_body_fetches.push(message_id);
1353    }
1354
1355    fn queue_thread_fetch(&mut self, thread_id: mxr_core::ThreadId) {
1356        if self.pending_thread_fetch.as_ref() == Some(&thread_id)
1357            || self.in_flight_thread_fetch.as_ref() == Some(&thread_id)
1358        {
1359            return;
1360        }
1361        self.pending_thread_fetch = Some(thread_id);
1362    }
1363
1364    fn envelope_preview(envelope: &Envelope) -> Option<String> {
1365        let snippet = envelope.snippet.trim();
1366        if snippet.is_empty() {
1367            None
1368        } else {
1369            Some(envelope.snippet.clone())
1370        }
1371    }
1372
1373    fn render_body(raw: &str, source: BodySource, reader_mode: bool) -> String {
1374        if !reader_mode {
1375            return raw.to_string();
1376        }
1377
1378        let config = mxr_reader::ReaderConfig::default();
1379        match source {
1380            BodySource::Plain => mxr_reader::clean(Some(raw), None, &config).content,
1381            BodySource::Html => mxr_reader::clean(None, Some(raw), &config).content,
1382            BodySource::Snippet => raw.to_string(),
1383        }
1384    }
1385
1386    fn resolve_body_view_state(&self, envelope: &Envelope) -> BodyViewState {
1387        let preview = Self::envelope_preview(envelope);
1388
1389        if let Some(body) = self.body_cache.get(&envelope.id) {
1390            if let Some(raw) = body.text_plain.clone() {
1391                let rendered = Self::render_body(&raw, BodySource::Plain, self.reader_mode);
1392                return BodyViewState::Ready {
1393                    raw,
1394                    rendered,
1395                    source: BodySource::Plain,
1396                };
1397            }
1398
1399            if let Some(raw) = body.text_html.clone() {
1400                let rendered = Self::render_body(&raw, BodySource::Html, self.reader_mode);
1401                return BodyViewState::Ready {
1402                    raw,
1403                    rendered,
1404                    source: BodySource::Html,
1405                };
1406            }
1407
1408            return BodyViewState::Empty { preview };
1409        }
1410
1411        if self.in_flight_body_requests.contains(&envelope.id) {
1412            BodyViewState::Loading { preview }
1413        } else {
1414            BodyViewState::Empty { preview }
1415        }
1416    }
1417
1418    pub fn resolve_body_success(&mut self, body: MessageBody) {
1419        let message_id = body.message_id.clone();
1420        self.in_flight_body_requests.remove(&message_id);
1421        self.body_cache.insert(message_id.clone(), body);
1422
1423        if self.viewing_envelope.as_ref().map(|env| env.id.clone()) == Some(message_id) {
1424            self.ensure_current_body_state();
1425        }
1426    }
1427
1428    pub fn resolve_body_fetch_error(&mut self, message_id: &MessageId, message: String) {
1429        self.in_flight_body_requests.remove(message_id);
1430
1431        if let Some(env) = self
1432            .viewing_envelope
1433            .as_ref()
1434            .filter(|env| &env.id == message_id)
1435        {
1436            self.body_view_state = BodyViewState::Error {
1437                message,
1438                preview: Self::envelope_preview(env),
1439            };
1440        }
1441    }
1442
1443    pub fn current_viewing_body(&self) -> Option<&MessageBody> {
1444        self.viewing_envelope
1445            .as_ref()
1446            .and_then(|env| self.body_cache.get(&env.id))
1447    }
1448
1449    pub fn selected_attachment(&self) -> Option<&AttachmentMeta> {
1450        self.attachment_panel
1451            .attachments
1452            .get(self.attachment_panel.selected_index)
1453    }
1454
1455    pub fn open_attachment_panel(&mut self) {
1456        let Some(message_id) = self.viewing_envelope.as_ref().map(|env| env.id.clone()) else {
1457            self.status_message = Some("No message selected".into());
1458            return;
1459        };
1460        let Some(attachments) = self
1461            .current_viewing_body()
1462            .map(|body| body.attachments.clone())
1463        else {
1464            self.status_message = Some("No message body loaded".into());
1465            return;
1466        };
1467        if attachments.is_empty() {
1468            self.status_message = Some("No attachments".into());
1469            return;
1470        }
1471
1472        self.attachment_panel.visible = true;
1473        self.attachment_panel.message_id = Some(message_id);
1474        self.attachment_panel.attachments = attachments;
1475        self.attachment_panel.selected_index = 0;
1476        self.attachment_panel.status = None;
1477    }
1478
1479    pub fn open_url_modal(&mut self) {
1480        let body = self.current_viewing_body();
1481        let Some(body) = body else {
1482            self.status_message = Some("No message body loaded".into());
1483            return;
1484        };
1485        let text_plain = body.text_plain.as_deref();
1486        let text_html = body.text_html.as_deref();
1487        let urls = ui::url_modal::extract_urls(text_plain, text_html);
1488        if urls.is_empty() {
1489            self.status_message = Some("No links found".into());
1490            return;
1491        }
1492        self.url_modal = Some(ui::url_modal::UrlModalState::new(urls));
1493    }
1494
1495    pub fn close_attachment_panel(&mut self) {
1496        self.attachment_panel = AttachmentPanelState::default();
1497        self.pending_attachment_action = None;
1498    }
1499
1500    pub fn queue_attachment_action(&mut self, operation: AttachmentOperation) {
1501        let Some(message_id) = self.attachment_panel.message_id.clone() else {
1502            return;
1503        };
1504        let Some(attachment) = self.selected_attachment().cloned() else {
1505            return;
1506        };
1507
1508        self.attachment_panel.status = Some(match operation {
1509            AttachmentOperation::Open => format!("Opening {}...", attachment.filename),
1510            AttachmentOperation::Download => format!("Downloading {}...", attachment.filename),
1511        });
1512        self.pending_attachment_action = Some(PendingAttachmentAction {
1513            message_id,
1514            attachment_id: attachment.id,
1515            operation,
1516        });
1517    }
1518
1519    pub fn resolve_attachment_file(&mut self, file: &mxr_protocol::AttachmentFile) {
1520        let path = std::path::PathBuf::from(&file.path);
1521        for attachment in &mut self.attachment_panel.attachments {
1522            if attachment.id == file.attachment_id {
1523                attachment.local_path = Some(path.clone());
1524            }
1525        }
1526        for body in self.body_cache.values_mut() {
1527            for attachment in &mut body.attachments {
1528                if attachment.id == file.attachment_id {
1529                    attachment.local_path = Some(path.clone());
1530                }
1531            }
1532        }
1533    }
1534
1535    fn label_chips_for_envelope(&self, envelope: &Envelope) -> Vec<String> {
1536        envelope
1537            .label_provider_ids
1538            .iter()
1539            .filter_map(|provider_id| {
1540                self.labels
1541                    .iter()
1542                    .find(|label| &label.provider_id == provider_id)
1543                    .map(|label| crate::ui::sidebar::humanize_label(&label.name).to_string())
1544            })
1545            .collect()
1546    }
1547
1548    fn attachment_summaries_for_envelope(&self, envelope: &Envelope) -> Vec<AttachmentSummary> {
1549        self.body_cache
1550            .get(&envelope.id)
1551            .map(|body| {
1552                body.attachments
1553                    .iter()
1554                    .map(|attachment| AttachmentSummary {
1555                        filename: attachment.filename.clone(),
1556                        size_bytes: attachment.size_bytes,
1557                    })
1558                    .collect()
1559            })
1560            .unwrap_or_default()
1561    }
1562
1563    fn thread_message_blocks(&self) -> Vec<ui::message_view::ThreadMessageBlock> {
1564        self.viewed_thread_messages
1565            .iter()
1566            .map(|message| ui::message_view::ThreadMessageBlock {
1567                envelope: message.clone(),
1568                body_state: self.resolve_body_view_state(message),
1569                labels: self.label_chips_for_envelope(message),
1570                attachments: self.attachment_summaries_for_envelope(message),
1571                selected: self.viewing_envelope.as_ref().map(|env| env.id.clone())
1572                    == Some(message.id.clone()),
1573                has_unsubscribe: !matches!(message.unsubscribe, UnsubscribeMethod::None),
1574                signature_expanded: self.signature_expanded,
1575            })
1576            .collect()
1577    }
1578
1579    pub fn apply_local_label_refs(
1580        &mut self,
1581        message_ids: &[MessageId],
1582        add: &[String],
1583        remove: &[String],
1584    ) {
1585        let add_provider_ids = self.resolve_label_provider_ids(add);
1586        let remove_provider_ids = self.resolve_label_provider_ids(remove);
1587        for envelope in self
1588            .envelopes
1589            .iter_mut()
1590            .chain(self.all_envelopes.iter_mut())
1591            .chain(self.search_page.results.iter_mut())
1592            .chain(self.viewed_thread_messages.iter_mut())
1593        {
1594            if message_ids
1595                .iter()
1596                .any(|message_id| message_id == &envelope.id)
1597            {
1598                apply_provider_label_changes(
1599                    &mut envelope.label_provider_ids,
1600                    &add_provider_ids,
1601                    &remove_provider_ids,
1602                );
1603            }
1604        }
1605        if let Some(ref mut envelope) = self.viewing_envelope {
1606            if message_ids
1607                .iter()
1608                .any(|message_id| message_id == &envelope.id)
1609            {
1610                apply_provider_label_changes(
1611                    &mut envelope.label_provider_ids,
1612                    &add_provider_ids,
1613                    &remove_provider_ids,
1614                );
1615            }
1616        }
1617    }
1618
1619    pub fn apply_local_flags(&mut self, message_id: &MessageId, flags: MessageFlags) {
1620        for envelope in self
1621            .envelopes
1622            .iter_mut()
1623            .chain(self.all_envelopes.iter_mut())
1624            .chain(self.search_page.results.iter_mut())
1625            .chain(self.viewed_thread_messages.iter_mut())
1626        {
1627            if &envelope.id == message_id {
1628                envelope.flags = flags;
1629            }
1630        }
1631        if let Some(envelope) = self.viewing_envelope.as_mut() {
1632            if &envelope.id == message_id {
1633                envelope.flags = flags;
1634            }
1635        }
1636    }
1637
1638    fn resolve_label_provider_ids(&self, refs: &[String]) -> Vec<String> {
1639        refs.iter()
1640            .filter_map(|label_ref| {
1641                self.labels
1642                    .iter()
1643                    .find(|label| label.provider_id == *label_ref || label.name == *label_ref)
1644                    .map(|label| label.provider_id.clone())
1645                    .or_else(|| Some(label_ref.clone()))
1646            })
1647            .collect()
1648    }
1649
1650    pub fn resolve_thread_success(&mut self, thread: Thread, mut messages: Vec<Envelope>) {
1651        let thread_id = thread.id.clone();
1652        self.in_flight_thread_fetch = None;
1653        messages.sort_by_key(|message| message.date);
1654
1655        if self
1656            .viewing_envelope
1657            .as_ref()
1658            .map(|env| env.thread_id.clone())
1659            == Some(thread_id)
1660        {
1661            let focused_message_id = self.focused_thread_envelope().map(|env| env.id.clone());
1662            for message in &messages {
1663                self.queue_body_fetch(message.id.clone());
1664            }
1665            self.viewed_thread = Some(thread);
1666            self.viewed_thread_messages = messages;
1667            self.thread_selected_index = focused_message_id
1668                .and_then(|message_id| {
1669                    self.viewed_thread_messages
1670                        .iter()
1671                        .position(|message| message.id == message_id)
1672                })
1673                .unwrap_or_else(|| self.default_thread_selected_index());
1674            self.sync_focused_thread_envelope();
1675        }
1676    }
1677
1678    pub fn resolve_thread_fetch_error(&mut self, thread_id: &mxr_core::ThreadId) {
1679        if self.in_flight_thread_fetch.as_ref() == Some(thread_id) {
1680            self.in_flight_thread_fetch = None;
1681        }
1682    }
1683
1684    /// Get IDs to mutate: selected_set if non-empty, else context_envelope.
1685    fn mutation_target_ids(&self) -> Vec<MessageId> {
1686        if !self.selected_set.is_empty() {
1687            self.selected_set.iter().cloned().collect()
1688        } else if let Some(env) = self.context_envelope() {
1689            vec![env.id.clone()]
1690        } else {
1691            vec![]
1692        }
1693    }
1694
1695    fn clear_selection(&mut self) {
1696        self.selected_set.clear();
1697        self.visual_mode = false;
1698        self.visual_anchor = None;
1699    }
1700
1701    fn queue_or_confirm_bulk_action(
1702        &mut self,
1703        title: impl Into<String>,
1704        detail: impl Into<String>,
1705        request: Request,
1706        effect: MutationEffect,
1707        status_message: String,
1708        count: usize,
1709    ) {
1710        if count > 1 {
1711            self.pending_bulk_confirm = Some(PendingBulkConfirm {
1712                title: title.into(),
1713                detail: detail.into(),
1714                request,
1715                effect,
1716                status_message,
1717            });
1718        } else {
1719            self.pending_mutation_queue.push((request, effect));
1720            self.status_message = Some(status_message);
1721            self.clear_selection();
1722        }
1723    }
1724
1725    /// Update visual selection range when moving in visual mode.
1726    fn update_visual_selection(&mut self) {
1727        if self.visual_mode {
1728            if let Some(anchor) = self.visual_anchor {
1729                let start = anchor.min(self.selected_index);
1730                let end = anchor.max(self.selected_index);
1731                self.selected_set.clear();
1732                for env in self.envelopes.iter().skip(start).take(end - start + 1) {
1733                    self.selected_set.insert(env.id.clone());
1734                }
1735            }
1736        }
1737    }
1738
1739    /// Ensure selected_index is visible within the scroll viewport.
1740    fn ensure_visible(&mut self) {
1741        let h = self.visible_height.max(1);
1742        if self.selected_index < self.scroll_offset {
1743            self.scroll_offset = self.selected_index;
1744        } else if self.selected_index >= self.scroll_offset + h {
1745            self.scroll_offset = self.selected_index + 1 - h;
1746        }
1747        // Prefetch bodies for messages near the cursor
1748        self.queue_body_window();
1749    }
1750
1751    pub fn set_subscriptions(&mut self, subscriptions: Vec<SubscriptionSummary>) {
1752        let selected_id = self
1753            .selected_subscription_entry()
1754            .map(|entry| entry.summary.latest_message_id.clone());
1755        self.subscriptions_page.entries = subscriptions
1756            .into_iter()
1757            .map(|summary| SubscriptionEntry {
1758                envelope: subscription_summary_to_envelope(&summary),
1759                summary,
1760            })
1761            .collect();
1762
1763        if self.subscriptions_page.entries.is_empty() {
1764            if self.mailbox_view == MailboxView::Subscriptions {
1765                self.selected_index = 0;
1766                self.scroll_offset = 0;
1767                self.viewing_envelope = None;
1768                self.viewed_thread = None;
1769                self.viewed_thread_messages.clear();
1770                self.body_view_state = BodyViewState::Empty { preview: None };
1771            }
1772            return;
1773        }
1774
1775        if let Some(selected_id) = selected_id {
1776            if let Some(position) = self
1777                .subscriptions_page
1778                .entries
1779                .iter()
1780                .position(|entry| entry.summary.latest_message_id == selected_id)
1781            {
1782                self.selected_index = position;
1783            } else {
1784                self.selected_index = self
1785                    .selected_index
1786                    .min(self.subscriptions_page.entries.len().saturating_sub(1));
1787            }
1788        } else {
1789            self.selected_index = self
1790                .selected_index
1791                .min(self.subscriptions_page.entries.len().saturating_sub(1));
1792        }
1793
1794        if self.mailbox_view == MailboxView::Subscriptions {
1795            self.ensure_visible();
1796            self.auto_preview();
1797        }
1798    }
1799}
1800
1801fn apply_provider_label_changes(
1802    label_provider_ids: &mut Vec<String>,
1803    add_provider_ids: &[String],
1804    remove_provider_ids: &[String],
1805) {
1806    label_provider_ids.retain(|provider_id| {
1807        !remove_provider_ids
1808            .iter()
1809            .any(|remove| remove == provider_id)
1810    });
1811    for provider_id in add_provider_ids {
1812        if !label_provider_ids
1813            .iter()
1814            .any(|existing| existing == provider_id)
1815        {
1816            label_provider_ids.push(provider_id.clone());
1817        }
1818    }
1819}
1820
1821fn unsubscribe_method_label(method: &UnsubscribeMethod) -> &'static str {
1822    match method {
1823        UnsubscribeMethod::OneClick { .. } => "one-click",
1824        UnsubscribeMethod::Mailto { .. } => "mailto",
1825        UnsubscribeMethod::HttpLink { .. } => "browser link",
1826        UnsubscribeMethod::BodyLink { .. } => "body link",
1827        UnsubscribeMethod::None => "none",
1828    }
1829}
1830
1831fn remove_from_list_effect(ids: &[MessageId]) -> MutationEffect {
1832    if ids.len() == 1 {
1833        MutationEffect::RemoveFromList(ids[0].clone())
1834    } else {
1835        MutationEffect::RemoveFromListMany(ids.to_vec())
1836    }
1837}
1838
1839fn pluralize_messages(count: usize) -> &'static str {
1840    if count == 1 {
1841        "message"
1842    } else {
1843        "messages"
1844    }
1845}
1846
1847fn bulk_message_detail(verb: &str, count: usize) -> String {
1848    format!(
1849        "You are about to {verb} these {count} {}.",
1850        pluralize_messages(count)
1851    )
1852}
1853
1854fn subscription_summary_to_envelope(summary: &SubscriptionSummary) -> Envelope {
1855    Envelope {
1856        id: summary.latest_message_id.clone(),
1857        account_id: summary.account_id.clone(),
1858        provider_id: summary.latest_provider_id.clone(),
1859        thread_id: summary.latest_thread_id.clone(),
1860        message_id_header: None,
1861        in_reply_to: None,
1862        references: vec![],
1863        from: Address {
1864            name: summary.sender_name.clone(),
1865            email: summary.sender_email.clone(),
1866        },
1867        to: vec![],
1868        cc: vec![],
1869        bcc: vec![],
1870        subject: summary.latest_subject.clone(),
1871        date: summary.latest_date,
1872        flags: summary.latest_flags,
1873        snippet: summary.latest_snippet.clone(),
1874        has_attachments: summary.latest_has_attachments,
1875        size_bytes: summary.latest_size_bytes,
1876        unsubscribe: summary.unsubscribe.clone(),
1877        label_provider_ids: vec![],
1878    }
1879}
1880
1881fn account_summary_to_config(
1882    account: &mxr_protocol::AccountSummaryData,
1883) -> Option<mxr_protocol::AccountConfigData> {
1884    Some(mxr_protocol::AccountConfigData {
1885        key: account.key.clone()?,
1886        name: account.name.clone(),
1887        email: account.email.clone(),
1888        sync: account.sync.clone(),
1889        send: account.send.clone(),
1890        is_default: account.is_default,
1891    })
1892}
1893
1894fn account_form_from_config(account: mxr_protocol::AccountConfigData) -> AccountFormState {
1895    let mut form = AccountFormState {
1896        visible: true,
1897        key: account.key,
1898        name: account.name,
1899        email: account.email,
1900        ..AccountFormState::default()
1901    };
1902
1903    if let Some(sync) = account.sync {
1904        match sync {
1905            mxr_protocol::AccountSyncConfigData::Gmail {
1906                credential_source,
1907                client_id,
1908                client_secret,
1909                token_ref,
1910            } => {
1911                form.mode = AccountFormMode::Gmail;
1912                form.gmail_credential_source = credential_source;
1913                form.gmail_client_id = client_id;
1914                form.gmail_client_secret = client_secret.unwrap_or_default();
1915                form.gmail_token_ref = token_ref;
1916            }
1917            mxr_protocol::AccountSyncConfigData::Imap {
1918                host,
1919                port,
1920                username,
1921                password_ref,
1922                ..
1923            } => {
1924                form.mode = AccountFormMode::ImapSmtp;
1925                form.imap_host = host;
1926                form.imap_port = port.to_string();
1927                form.imap_username = username;
1928                form.imap_password_ref = password_ref;
1929            }
1930        }
1931    } else {
1932        form.mode = AccountFormMode::SmtpOnly;
1933    }
1934
1935    match account.send {
1936        Some(mxr_protocol::AccountSendConfigData::Smtp {
1937            host,
1938            port,
1939            username,
1940            password_ref,
1941            ..
1942        }) => {
1943            form.smtp_host = host;
1944            form.smtp_port = port.to_string();
1945            form.smtp_username = username;
1946            form.smtp_password_ref = password_ref;
1947        }
1948        Some(mxr_protocol::AccountSendConfigData::Gmail) => {
1949            if form.gmail_token_ref.is_empty() {
1950                form.gmail_token_ref = format!("mxr/{}-gmail", form.key);
1951            }
1952        }
1953        None => {}
1954    }
1955
1956    form
1957}
1958
1959fn account_form_field_value(form: &AccountFormState) -> Option<&str> {
1960    match (form.mode, form.active_field) {
1961        (_, 0) => None,
1962        (_, 1) => Some(form.key.as_str()),
1963        (_, 2) => Some(form.name.as_str()),
1964        (_, 3) => Some(form.email.as_str()),
1965        (AccountFormMode::Gmail, 4) => None,
1966        (AccountFormMode::Gmail, 5)
1967            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
1968        {
1969            Some(form.gmail_client_id.as_str())
1970        }
1971        (AccountFormMode::Gmail, 6)
1972            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
1973        {
1974            Some(form.gmail_client_secret.as_str())
1975        }
1976        (AccountFormMode::Gmail, 5 | 6) => None,
1977        (AccountFormMode::Gmail, 7) => None,
1978        (AccountFormMode::ImapSmtp, 4) => Some(form.imap_host.as_str()),
1979        (AccountFormMode::ImapSmtp, 5) => Some(form.imap_port.as_str()),
1980        (AccountFormMode::ImapSmtp, 6) => Some(form.imap_username.as_str()),
1981        (AccountFormMode::ImapSmtp, 7) => Some(form.imap_password_ref.as_str()),
1982        (AccountFormMode::ImapSmtp, 8) => Some(form.imap_password.as_str()),
1983        (AccountFormMode::ImapSmtp, 9) => Some(form.smtp_host.as_str()),
1984        (AccountFormMode::ImapSmtp, 10) => Some(form.smtp_port.as_str()),
1985        (AccountFormMode::ImapSmtp, 11) => Some(form.smtp_username.as_str()),
1986        (AccountFormMode::ImapSmtp, 12) => Some(form.smtp_password_ref.as_str()),
1987        (AccountFormMode::ImapSmtp, 13) => Some(form.smtp_password.as_str()),
1988        (AccountFormMode::SmtpOnly, 4) => Some(form.smtp_host.as_str()),
1989        (AccountFormMode::SmtpOnly, 5) => Some(form.smtp_port.as_str()),
1990        (AccountFormMode::SmtpOnly, 6) => Some(form.smtp_username.as_str()),
1991        (AccountFormMode::SmtpOnly, 7) => Some(form.smtp_password_ref.as_str()),
1992        (AccountFormMode::SmtpOnly, 8) => Some(form.smtp_password.as_str()),
1993        _ => None,
1994    }
1995}
1996
1997fn account_form_field_is_editable(form: &AccountFormState) -> bool {
1998    account_form_field_value(form).is_some()
1999}
2000
2001fn with_account_form_field_mut<F>(form: &mut AccountFormState, mut update: F)
2002where
2003    F: FnMut(&mut String),
2004{
2005    let field = match (form.mode, form.active_field) {
2006        (_, 1) => &mut form.key,
2007        (_, 2) => &mut form.name,
2008        (_, 3) => &mut form.email,
2009        (AccountFormMode::Gmail, 5)
2010            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2011        {
2012            &mut form.gmail_client_id
2013        }
2014        (AccountFormMode::Gmail, 6)
2015            if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2016        {
2017            &mut form.gmail_client_secret
2018        }
2019        (AccountFormMode::ImapSmtp, 4) => &mut form.imap_host,
2020        (AccountFormMode::ImapSmtp, 5) => &mut form.imap_port,
2021        (AccountFormMode::ImapSmtp, 6) => &mut form.imap_username,
2022        (AccountFormMode::ImapSmtp, 7) => &mut form.imap_password_ref,
2023        (AccountFormMode::ImapSmtp, 8) => &mut form.imap_password,
2024        (AccountFormMode::ImapSmtp, 9) => &mut form.smtp_host,
2025        (AccountFormMode::ImapSmtp, 10) => &mut form.smtp_port,
2026        (AccountFormMode::ImapSmtp, 11) => &mut form.smtp_username,
2027        (AccountFormMode::ImapSmtp, 12) => &mut form.smtp_password_ref,
2028        (AccountFormMode::ImapSmtp, 13) => &mut form.smtp_password,
2029        (AccountFormMode::SmtpOnly, 4) => &mut form.smtp_host,
2030        (AccountFormMode::SmtpOnly, 5) => &mut form.smtp_port,
2031        (AccountFormMode::SmtpOnly, 6) => &mut form.smtp_username,
2032        (AccountFormMode::SmtpOnly, 7) => &mut form.smtp_password_ref,
2033        (AccountFormMode::SmtpOnly, 8) => &mut form.smtp_password,
2034        _ => return,
2035    };
2036    update(field);
2037}
2038
2039fn insert_account_form_char(form: &mut AccountFormState, c: char) {
2040    let cursor = form.field_cursor;
2041    with_account_form_field_mut(form, |value| {
2042        let insert_at = char_to_byte_index(value, cursor);
2043        value.insert(insert_at, c);
2044    });
2045    form.field_cursor = form.field_cursor.saturating_add(1);
2046}
2047
2048fn delete_account_form_char(form: &mut AccountFormState, backspace: bool) {
2049    let cursor = form.field_cursor;
2050    with_account_form_field_mut(form, |value| {
2051        if backspace {
2052            if cursor == 0 {
2053                return;
2054            }
2055            let start = char_to_byte_index(value, cursor - 1);
2056            let end = char_to_byte_index(value, cursor);
2057            value.replace_range(start..end, "");
2058        } else {
2059            let len = value.chars().count();
2060            if cursor >= len {
2061                return;
2062            }
2063            let start = char_to_byte_index(value, cursor);
2064            let end = char_to_byte_index(value, cursor + 1);
2065            value.replace_range(start..end, "");
2066        }
2067    });
2068    if backspace {
2069        form.field_cursor = form.field_cursor.saturating_sub(1);
2070    }
2071}
2072
2073fn char_to_byte_index(value: &str, char_index: usize) -> usize {
2074    value
2075        .char_indices()
2076        .nth(char_index)
2077        .map(|(index, _)| index)
2078        .unwrap_or(value.len())
2079}
2080
2081fn next_gmail_credential_source(
2082    current: mxr_protocol::GmailCredentialSourceData,
2083    forward: bool,
2084) -> mxr_protocol::GmailCredentialSourceData {
2085    match (current, forward) {
2086        (mxr_protocol::GmailCredentialSourceData::Bundled, true) => {
2087            mxr_protocol::GmailCredentialSourceData::Custom
2088        }
2089        (mxr_protocol::GmailCredentialSourceData::Custom, true) => {
2090            mxr_protocol::GmailCredentialSourceData::Bundled
2091        }
2092        (mxr_protocol::GmailCredentialSourceData::Bundled, false) => {
2093            mxr_protocol::GmailCredentialSourceData::Custom
2094        }
2095        (mxr_protocol::GmailCredentialSourceData::Custom, false) => {
2096            mxr_protocol::GmailCredentialSourceData::Bundled
2097        }
2098    }
2099}
2100
2101pub fn snooze_presets() -> [SnoozePreset; 4] {
2102    [
2103        SnoozePreset::TomorrowMorning,
2104        SnoozePreset::Tonight,
2105        SnoozePreset::Weekend,
2106        SnoozePreset::NextMonday,
2107    ]
2108}
2109
2110pub fn resolve_snooze_preset(
2111    preset: SnoozePreset,
2112    config: &mxr_config::SnoozeConfig,
2113) -> chrono::DateTime<chrono::Utc> {
2114    use chrono::{Datelike, Duration, Local, NaiveTime, Weekday};
2115
2116    let now = Local::now();
2117    match preset {
2118        SnoozePreset::TomorrowMorning => {
2119            let tomorrow = now.date_naive() + Duration::days(1);
2120            let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2121            tomorrow
2122                .and_time(time)
2123                .and_local_timezone(now.timezone())
2124                .single()
2125                .unwrap()
2126                .with_timezone(&chrono::Utc)
2127        }
2128        SnoozePreset::Tonight => {
2129            let today = now.date_naive();
2130            let time = NaiveTime::from_hms_opt(config.evening_hour as u32, 0, 0).unwrap();
2131            let tonight = today
2132                .and_time(time)
2133                .and_local_timezone(now.timezone())
2134                .single()
2135                .unwrap()
2136                .with_timezone(&chrono::Utc);
2137            if tonight <= chrono::Utc::now() {
2138                tonight + Duration::days(1)
2139            } else {
2140                tonight
2141            }
2142        }
2143        SnoozePreset::Weekend => {
2144            let target_day = match config.weekend_day.as_str() {
2145                "sunday" => Weekday::Sun,
2146                _ => Weekday::Sat,
2147            };
2148            let days_until = (target_day.num_days_from_monday() as i64
2149                - now.weekday().num_days_from_monday() as i64
2150                + 7)
2151                % 7;
2152            let days = if days_until == 0 { 7 } else { days_until };
2153            let weekend = now.date_naive() + Duration::days(days);
2154            let time = NaiveTime::from_hms_opt(config.weekend_hour as u32, 0, 0).unwrap();
2155            weekend
2156                .and_time(time)
2157                .and_local_timezone(now.timezone())
2158                .single()
2159                .unwrap()
2160                .with_timezone(&chrono::Utc)
2161        }
2162        SnoozePreset::NextMonday => {
2163            let days_until_monday = (Weekday::Mon.num_days_from_monday() as i64
2164                - now.weekday().num_days_from_monday() as i64
2165                + 7)
2166                % 7;
2167            let days = if days_until_monday == 0 {
2168                7
2169            } else {
2170                days_until_monday
2171            };
2172            let monday = now.date_naive() + Duration::days(days);
2173            let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2174            monday
2175                .and_time(time)
2176                .and_local_timezone(now.timezone())
2177                .single()
2178                .unwrap()
2179                .with_timezone(&chrono::Utc)
2180        }
2181    }
2182}