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