Skip to main content

mxr_tui/app/
mod.rs

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