Skip to main content

mxr_tui/app/
mod.rs

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