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