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 let mut filtered = filtered;
1458 filtered.sort_by(|left, right| {
1459 sane_mail_sort_timestamp(&right.date)
1460 .cmp(&sane_mail_sort_timestamp(&left.date))
1461 .then_with(|| right.id.as_str().cmp(&left.id.as_str()))
1462 });
1463 self.search_page.results = filtered
1464 .into_iter()
1465 .take(SEARCH_PAGE_SIZE as usize)
1466 .collect();
1467 self.search_page.scores.clear();
1468 self.search_page.has_more = false;
1469 self.search_page.loading_more = true;
1470 self.search_page.load_to_end = false;
1471 self.search_page.session_active = true;
1472 self.search_page.active_pane = SearchPane::Results;
1473 let session_id = Self::bump_search_session_id(&mut self.search_page.session_id);
1474 self.queue_search_request(
1475 SearchTarget::SearchPage,
1476 false,
1477 query_source,
1478 self.search_page.mode,
1479 self.search_page.sort.clone(),
1480 0,
1481 session_id,
1482 );
1483 } else {
1484 let mut filtered = filtered;
1485 filtered.sort_by(|left, right| {
1486 sane_mail_sort_timestamp(&right.date)
1487 .cmp(&sane_mail_sort_timestamp(&left.date))
1488 .then_with(|| right.id.as_str().cmp(&left.id.as_str()))
1489 });
1490 self.envelopes = filtered;
1491 self.search_active = true;
1492 let session_id = Self::bump_search_session_id(&mut self.mailbox_search_session_id);
1493 self.queue_search_request(
1494 SearchTarget::Mailbox,
1495 false,
1496 query_source,
1497 self.search_bar.mode,
1498 SortOrder::DateDesc,
1499 0,
1500 session_id,
1501 );
1502 }
1503 }
1504 if self.screen == Screen::Search {
1505 self.search_page.selected_index = 0;
1506 self.search_page.scroll_offset = 0;
1507 self.auto_preview_search();
1508 } else {
1509 self.selected_index = 0;
1510 self.scroll_offset = 0;
1511 }
1512 }
1513
1514 pub fn mail_list_title(&self) -> String {
1516 if self.mailbox_view == MailboxView::Subscriptions {
1517 return format!("Subscriptions ({})", self.subscriptions_page.entries.len());
1518 }
1519
1520 let list_name = match self.mail_list_mode {
1521 MailListMode::Threads => "Threads",
1522 MailListMode::Messages => "Messages",
1523 };
1524 let list_count = self.mail_row_count();
1525 if self.search_active {
1526 format!("Search: {} ({list_count})", self.search_bar.query)
1527 } else if let Some(label_id) = self
1528 .pending_active_label
1529 .as_ref()
1530 .or(self.active_label.as_ref())
1531 {
1532 if let Some(label) = self.labels.iter().find(|l| &l.id == label_id) {
1533 let name = crate::ui::sidebar::humanize_label(&label.name);
1534 format!("{name} {list_name} ({list_count})")
1535 } else {
1536 format!("{list_name} ({list_count})")
1537 }
1538 } else {
1539 format!("All Mail {list_name} ({list_count})")
1540 }
1541 }
1542
1543 fn all_mail_envelopes(&self) -> Vec<Envelope> {
1544 self.all_envelopes
1545 .iter()
1546 .filter(|envelope| !envelope.flags.contains(MessageFlags::TRASH))
1547 .cloned()
1548 .collect()
1549 }
1550
1551 fn active_label_record(&self) -> Option<&Label> {
1552 let label_id = self
1553 .pending_active_label
1554 .as_ref()
1555 .or(self.active_label.as_ref())?;
1556 self.labels.iter().find(|label| &label.id == label_id)
1557 }
1558
1559 fn global_starred_count(&self) -> usize {
1560 self.labels
1561 .iter()
1562 .find(|label| label.name.eq_ignore_ascii_case("STARRED"))
1563 .map(|label| label.total_count as usize)
1564 .unwrap_or_else(|| {
1565 self.all_envelopes
1566 .iter()
1567 .filter(|envelope| envelope.flags.contains(MessageFlags::STARRED))
1568 .count()
1569 })
1570 }
1571
1572 pub fn status_bar_state(&self) -> ui::status_bar::StatusBarState {
1573 let starred_count = self.global_starred_count();
1574
1575 if self.mailbox_view == MailboxView::Subscriptions {
1576 let unread_count = self
1577 .subscriptions_page
1578 .entries
1579 .iter()
1580 .filter(|entry| !entry.envelope.flags.contains(MessageFlags::READ))
1581 .count();
1582 return ui::status_bar::StatusBarState {
1583 mailbox_name: "SUBSCRIPTIONS".into(),
1584 total_count: self.subscriptions_page.entries.len(),
1585 unread_count,
1586 starred_count,
1587 sync_status: self.last_sync_status.clone(),
1588 status_message: self.status_message.clone(),
1589 pending_mutation_count: self.pending_mutation_count,
1590 pending_mutation_status: self.pending_mutation_status.clone(),
1591 };
1592 }
1593
1594 if self.screen == Screen::Search || self.search_active {
1595 let results = if self.screen == Screen::Search {
1596 &self.search_page.results
1597 } else {
1598 &self.envelopes
1599 };
1600 let unread_count = results
1601 .iter()
1602 .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1603 .count();
1604 return ui::status_bar::StatusBarState {
1605 mailbox_name: "SEARCH".into(),
1606 total_count: results.len(),
1607 unread_count,
1608 starred_count,
1609 sync_status: self.last_sync_status.clone(),
1610 status_message: self.status_message.clone(),
1611 pending_mutation_count: self.pending_mutation_count,
1612 pending_mutation_status: self.pending_mutation_status.clone(),
1613 };
1614 }
1615
1616 if let Some(label) = self.active_label_record() {
1617 return ui::status_bar::StatusBarState {
1618 mailbox_name: label.name.clone(),
1619 total_count: label.total_count as usize,
1620 unread_count: label.unread_count as usize,
1621 starred_count,
1622 sync_status: self.last_sync_status.clone(),
1623 status_message: self.status_message.clone(),
1624 pending_mutation_count: self.pending_mutation_count,
1625 pending_mutation_status: self.pending_mutation_status.clone(),
1626 };
1627 }
1628
1629 let unread_count = self
1630 .envelopes
1631 .iter()
1632 .filter(|envelope| !envelope.flags.contains(MessageFlags::READ))
1633 .count();
1634 ui::status_bar::StatusBarState {
1635 mailbox_name: "ALL MAIL".into(),
1636 total_count: self
1637 .diagnostics_page
1638 .total_messages
1639 .map(|count| count as usize)
1640 .unwrap_or_else(|| self.all_envelopes.len()),
1641 unread_count,
1642 starred_count,
1643 sync_status: self.last_sync_status.clone(),
1644 status_message: self.status_message.clone(),
1645 pending_mutation_count: self.pending_mutation_count,
1646 pending_mutation_status: self.pending_mutation_status.clone(),
1647 }
1648 }
1649
1650 fn summarize_sync_status(sync_statuses: &[mxr_protocol::AccountSyncStatus]) -> String {
1651 if sync_statuses.is_empty() {
1652 return "not synced".into();
1653 }
1654 if sync_statuses.iter().any(|sync| sync.sync_in_progress) {
1655 return "syncing".into();
1656 }
1657 if sync_statuses
1658 .iter()
1659 .any(|sync| !sync.healthy || sync.last_error.is_some())
1660 {
1661 return "degraded".into();
1662 }
1663 sync_statuses
1664 .iter()
1665 .filter_map(|sync| sync.last_success_at.as_deref())
1666 .filter_map(Self::format_sync_age)
1667 .max_by_key(|(_, sort_key)| *sort_key)
1668 .map(|(display, _)| format!("synced {display}"))
1669 .unwrap_or_else(|| "not synced".into())
1670 }
1671
1672 fn format_sync_age(timestamp: &str) -> Option<(String, i64)> {
1673 let parsed = chrono::DateTime::parse_from_rfc3339(timestamp).ok()?;
1674 let synced_at = parsed.with_timezone(&chrono::Utc);
1675 let elapsed = chrono::Utc::now().signed_duration_since(synced_at);
1676 let seconds = elapsed.num_seconds().max(0);
1677 let display = if seconds < 60 {
1678 "just now".to_string()
1679 } else if seconds < 3_600 {
1680 format!("{}m ago", seconds / 60)
1681 } else if seconds < 86_400 {
1682 format!("{}h ago", seconds / 3_600)
1683 } else {
1684 format!("{}d ago", seconds / 86_400)
1685 };
1686 Some((display, synced_at.timestamp()))
1687 }
1688
1689 pub fn resolve_desired_system_mailbox(&mut self) {
1690 let Some(target) = self.desired_system_mailbox.as_deref() else {
1691 return;
1692 };
1693 if self.pending_active_label.is_some() || self.active_label.is_some() {
1694 return;
1695 }
1696 if let Some(label_id) = self
1697 .labels
1698 .iter()
1699 .find(|label| label.name.eq_ignore_ascii_case(target))
1700 .map(|label| label.id.clone())
1701 {
1702 self.apply(Action::SelectLabel(label_id));
1703 }
1704 }
1705
1706 fn auto_preview(&mut self) {
1708 if self.mailbox_view == MailboxView::Subscriptions {
1709 if let Some(entry) = self.selected_subscription_entry().cloned() {
1710 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&entry.envelope.id) {
1711 self.open_envelope(entry.envelope);
1712 }
1713 } else {
1714 self.pending_preview_read = None;
1715 self.viewing_envelope = None;
1716 self.viewed_thread = None;
1717 self.viewed_thread_messages.clear();
1718 self.body_view_state = BodyViewState::Empty { preview: None };
1719 }
1720 return;
1721 }
1722
1723 if self.layout_mode == LayoutMode::ThreePane {
1724 if let Some(row) = self.selected_mail_row() {
1725 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&row.representative.id) {
1726 self.open_envelope(row.representative);
1727 }
1728 }
1729 }
1730 }
1731
1732 pub fn auto_preview_search(&mut self) {
1733 if let Some(env) = self.selected_search_envelope().cloned() {
1734 if self
1735 .viewing_envelope
1736 .as_ref()
1737 .map(|current| current.id.clone())
1738 != Some(env.id.clone())
1739 {
1740 self.open_envelope(env);
1741 }
1742 } else if self.screen == Screen::Search {
1743 self.clear_message_view_state();
1744 }
1745 }
1746
1747 pub(crate) fn ensure_search_visible(&mut self) {
1748 let h = self.visible_height.max(1);
1749 if self.search_page.selected_index < self.search_page.scroll_offset {
1750 self.search_page.scroll_offset = self.search_page.selected_index;
1751 } else if self.search_page.selected_index >= self.search_page.scroll_offset + h {
1752 self.search_page.scroll_offset = self.search_page.selected_index + 1 - h;
1753 }
1754 }
1755
1756 pub fn queue_body_window(&mut self) {
1759 const BUFFER: usize = 50;
1760 let source_envelopes: Vec<Envelope> = if self.mailbox_view == MailboxView::Subscriptions {
1761 self.subscriptions_page
1762 .entries
1763 .iter()
1764 .map(|entry| entry.envelope.clone())
1765 .collect()
1766 } else {
1767 self.envelopes.clone()
1768 };
1769 let len = source_envelopes.len();
1770 if len == 0 {
1771 return;
1772 }
1773 let start = self.selected_index.saturating_sub(BUFFER / 2);
1774 let end = (self.selected_index + BUFFER / 2).min(len);
1775 let ids: Vec<MessageId> = source_envelopes[start..end]
1776 .iter()
1777 .map(|e| e.id.clone())
1778 .collect();
1779 for id in ids {
1780 self.queue_body_fetch(id);
1781 }
1782 }
1783
1784 fn open_envelope(&mut self, env: Envelope) {
1785 self.close_attachment_panel();
1786 self.signature_expanded = false;
1787 self.viewed_thread = None;
1788 self.viewed_thread_messages = self.optimistic_thread_messages(&env);
1789 self.thread_selected_index = self.default_thread_selected_index();
1790 self.viewing_envelope = self.focused_thread_envelope().cloned();
1791 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1792 self.schedule_preview_read(&viewing_envelope);
1793 }
1794 for message in self.viewed_thread_messages.clone() {
1795 self.queue_body_fetch(message.id);
1796 }
1797 self.queue_thread_fetch(env.thread_id.clone());
1798 self.message_scroll_offset = 0;
1799 self.ensure_current_body_state();
1800 }
1801
1802 fn optimistic_thread_messages(&self, env: &Envelope) -> Vec<Envelope> {
1803 let mut messages: Vec<Envelope> = self
1804 .all_envelopes
1805 .iter()
1806 .filter(|candidate| candidate.thread_id == env.thread_id)
1807 .cloned()
1808 .collect();
1809 if messages.is_empty() {
1810 messages.push(env.clone());
1811 }
1812 messages.sort_by_key(|message| message.date);
1813 messages
1814 }
1815
1816 fn default_thread_selected_index(&self) -> usize {
1817 self.viewed_thread_messages
1818 .iter()
1819 .rposition(|message| !message.flags.contains(MessageFlags::READ))
1820 .or_else(|| self.viewed_thread_messages.len().checked_sub(1))
1821 .unwrap_or(0)
1822 }
1823
1824 fn sync_focused_thread_envelope(&mut self) {
1825 self.close_attachment_panel();
1826 self.viewing_envelope = self.focused_thread_envelope().cloned();
1827 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1828 self.schedule_preview_read(&viewing_envelope);
1829 } else {
1830 self.pending_preview_read = None;
1831 }
1832 self.message_scroll_offset = 0;
1833 self.ensure_current_body_state();
1834 }
1835
1836 fn schedule_preview_read(&mut self, envelope: &Envelope) {
1837 if envelope.flags.contains(MessageFlags::READ)
1838 || self.has_pending_set_read(&envelope.id, true)
1839 {
1840 self.pending_preview_read = None;
1841 return;
1842 }
1843
1844 if self
1845 .pending_preview_read
1846 .as_ref()
1847 .is_some_and(|pending| pending.message_id == envelope.id)
1848 {
1849 return;
1850 }
1851
1852 self.pending_preview_read = Some(PendingPreviewRead {
1853 message_id: envelope.id.clone(),
1854 due_at: Instant::now() + PREVIEW_MARK_READ_DELAY,
1855 });
1856 }
1857
1858 fn has_pending_set_read(&self, message_id: &MessageId, read: bool) -> bool {
1859 self.pending_mutation_queue.iter().any(|(request, _)| {
1860 matches!(
1861 request,
1862 Request::Mutation(MutationCommand::SetRead { message_ids, read: queued_read })
1863 if *queued_read == read
1864 && message_ids.len() == 1
1865 && message_ids[0] == *message_id
1866 )
1867 })
1868 }
1869
1870 fn process_pending_preview_read(&mut self) {
1871 let Some(pending) = self.pending_preview_read.clone() else {
1872 return;
1873 };
1874 if Instant::now() < pending.due_at {
1875 return;
1876 }
1877 self.pending_preview_read = None;
1878
1879 let Some(envelope) = self
1880 .viewing_envelope
1881 .clone()
1882 .filter(|envelope| envelope.id == pending.message_id)
1883 else {
1884 return;
1885 };
1886
1887 if envelope.flags.contains(MessageFlags::READ)
1888 || self.has_pending_set_read(&envelope.id, true)
1889 {
1890 return;
1891 }
1892
1893 let mut flags = envelope.flags;
1894 flags.insert(MessageFlags::READ);
1895 self.apply_local_flags(&envelope.id, flags);
1896 self.queue_mutation(
1897 Request::Mutation(MutationCommand::SetRead {
1898 message_ids: vec![envelope.id.clone()],
1899 read: true,
1900 }),
1901 MutationEffect::StatusOnly("Marked message as read".into()),
1902 "Marking message as read...".into(),
1903 );
1904 }
1905
1906 pub fn next_background_timeout(&self, fallback: Duration) -> Duration {
1907 let Some(pending) = self.pending_preview_read.as_ref() else {
1908 return fallback;
1909 };
1910 fallback.min(
1911 pending
1912 .due_at
1913 .checked_duration_since(Instant::now())
1914 .unwrap_or(Duration::ZERO),
1915 )
1916 }
1917
1918 #[cfg(test)]
1919 pub fn expire_pending_preview_read_for_tests(&mut self) {
1920 if let Some(pending) = self.pending_preview_read.as_mut() {
1921 pending.due_at = Instant::now();
1922 }
1923 }
1924
1925 fn move_thread_focus_down(&mut self) {
1926 if self.thread_selected_index + 1 < self.viewed_thread_messages.len() {
1927 self.thread_selected_index += 1;
1928 self.sync_focused_thread_envelope();
1929 }
1930 }
1931
1932 fn move_thread_focus_up(&mut self) {
1933 if self.thread_selected_index > 0 {
1934 self.thread_selected_index -= 1;
1935 self.sync_focused_thread_envelope();
1936 }
1937 }
1938
1939 fn ensure_current_body_state(&mut self) {
1940 if let Some(env) = self.viewing_envelope.clone() {
1941 if !self.body_cache.contains_key(&env.id) {
1942 self.queue_body_fetch(env.id.clone());
1943 }
1944 self.body_view_state = self.resolve_body_view_state(&env);
1945 } else {
1946 self.body_view_state = BodyViewState::Empty { preview: None };
1947 }
1948 }
1949
1950 fn queue_body_fetch(&mut self, message_id: MessageId) {
1951 if self.body_cache.contains_key(&message_id)
1952 || self.in_flight_body_requests.contains(&message_id)
1953 || self.queued_body_fetches.contains(&message_id)
1954 {
1955 return;
1956 }
1957
1958 self.in_flight_body_requests.insert(message_id.clone());
1959 self.queued_body_fetches.push(message_id);
1960 }
1961
1962 fn queue_thread_fetch(&mut self, thread_id: mxr_core::ThreadId) {
1963 if self.pending_thread_fetch.as_ref() == Some(&thread_id)
1964 || self.in_flight_thread_fetch.as_ref() == Some(&thread_id)
1965 {
1966 return;
1967 }
1968 self.pending_thread_fetch = Some(thread_id);
1969 }
1970
1971 fn envelope_preview(envelope: &Envelope) -> Option<String> {
1972 let snippet = envelope.snippet.trim();
1973 if snippet.is_empty() {
1974 None
1975 } else {
1976 Some(envelope.snippet.clone())
1977 }
1978 }
1979
1980 fn render_body(raw: &str, source: BodySource, reader_mode: bool) -> String {
1981 if !reader_mode {
1982 return raw.to_string();
1983 }
1984
1985 let config = mxr_reader::ReaderConfig::default();
1986 match source {
1987 BodySource::Plain => mxr_reader::clean(Some(raw), None, &config).content,
1988 BodySource::Html => mxr_reader::clean(None, Some(raw), &config).content,
1989 BodySource::Snippet => raw.to_string(),
1990 }
1991 }
1992
1993 fn resolve_body_view_state(&self, envelope: &Envelope) -> BodyViewState {
1994 let preview = Self::envelope_preview(envelope);
1995
1996 if let Some(body) = self.body_cache.get(&envelope.id) {
1997 if let Some(raw) = body.text_plain.clone() {
1998 let rendered = Self::render_body(&raw, BodySource::Plain, self.reader_mode);
1999 return BodyViewState::Ready {
2000 raw,
2001 rendered,
2002 source: BodySource::Plain,
2003 };
2004 }
2005
2006 if let Some(raw) = body.text_html.clone() {
2007 let rendered = Self::render_body(&raw, BodySource::Html, self.reader_mode);
2008 return BodyViewState::Ready {
2009 raw,
2010 rendered,
2011 source: BodySource::Html,
2012 };
2013 }
2014
2015 return BodyViewState::Empty { preview };
2016 }
2017
2018 if self.in_flight_body_requests.contains(&envelope.id) {
2019 BodyViewState::Loading { preview }
2020 } else {
2021 BodyViewState::Empty { preview }
2022 }
2023 }
2024
2025 pub fn resolve_body_success(&mut self, body: MessageBody) {
2026 let message_id = body.message_id.clone();
2027 self.in_flight_body_requests.remove(&message_id);
2028 self.body_cache.insert(message_id.clone(), body);
2029
2030 if self.viewing_envelope.as_ref().map(|env| env.id.clone()) == Some(message_id) {
2031 self.ensure_current_body_state();
2032 }
2033 }
2034
2035 pub fn resolve_body_fetch_error(&mut self, message_id: &MessageId, message: String) {
2036 self.in_flight_body_requests.remove(message_id);
2037
2038 if let Some(env) = self
2039 .viewing_envelope
2040 .as_ref()
2041 .filter(|env| &env.id == message_id)
2042 {
2043 self.body_view_state = BodyViewState::Error {
2044 message,
2045 preview: Self::envelope_preview(env),
2046 };
2047 }
2048 }
2049
2050 pub fn current_viewing_body(&self) -> Option<&MessageBody> {
2051 self.viewing_envelope
2052 .as_ref()
2053 .and_then(|env| self.body_cache.get(&env.id))
2054 }
2055
2056 pub fn selected_attachment(&self) -> Option<&AttachmentMeta> {
2057 self.attachment_panel
2058 .attachments
2059 .get(self.attachment_panel.selected_index)
2060 }
2061
2062 pub fn open_attachment_panel(&mut self) {
2063 let Some(message_id) = self.viewing_envelope.as_ref().map(|env| env.id.clone()) else {
2064 self.status_message = Some("No message selected".into());
2065 return;
2066 };
2067 let Some(attachments) = self
2068 .current_viewing_body()
2069 .map(|body| body.attachments.clone())
2070 else {
2071 self.status_message = Some("No message body loaded".into());
2072 return;
2073 };
2074 if attachments.is_empty() {
2075 self.status_message = Some("No attachments".into());
2076 return;
2077 }
2078
2079 self.attachment_panel.visible = true;
2080 self.attachment_panel.message_id = Some(message_id);
2081 self.attachment_panel.attachments = attachments;
2082 self.attachment_panel.selected_index = 0;
2083 self.attachment_panel.status = None;
2084 }
2085
2086 pub fn open_url_modal(&mut self) {
2087 let body = self.current_viewing_body();
2088 let Some(body) = body else {
2089 self.status_message = Some("No message body loaded".into());
2090 return;
2091 };
2092 let text_plain = body.text_plain.as_deref();
2093 let text_html = body.text_html.as_deref();
2094 let urls = ui::url_modal::extract_urls(text_plain, text_html);
2095 if urls.is_empty() {
2096 self.status_message = Some("No links found".into());
2097 return;
2098 }
2099 self.url_modal = Some(ui::url_modal::UrlModalState::new(urls));
2100 }
2101
2102 pub fn close_attachment_panel(&mut self) {
2103 self.attachment_panel = AttachmentPanelState::default();
2104 self.pending_attachment_action = None;
2105 }
2106
2107 pub fn queue_attachment_action(&mut self, operation: AttachmentOperation) {
2108 let Some(message_id) = self.attachment_panel.message_id.clone() else {
2109 return;
2110 };
2111 let Some(attachment) = self.selected_attachment().cloned() else {
2112 return;
2113 };
2114
2115 self.attachment_panel.status = Some(match operation {
2116 AttachmentOperation::Open => format!("Opening {}...", attachment.filename),
2117 AttachmentOperation::Download => format!("Downloading {}...", attachment.filename),
2118 });
2119 self.pending_attachment_action = Some(PendingAttachmentAction {
2120 message_id,
2121 attachment_id: attachment.id,
2122 operation,
2123 });
2124 }
2125
2126 pub fn resolve_attachment_file(&mut self, file: &mxr_protocol::AttachmentFile) {
2127 let path = std::path::PathBuf::from(&file.path);
2128 for attachment in &mut self.attachment_panel.attachments {
2129 if attachment.id == file.attachment_id {
2130 attachment.local_path = Some(path.clone());
2131 }
2132 }
2133 for body in self.body_cache.values_mut() {
2134 for attachment in &mut body.attachments {
2135 if attachment.id == file.attachment_id {
2136 attachment.local_path = Some(path.clone());
2137 }
2138 }
2139 }
2140 }
2141
2142 fn label_chips_for_envelope(&self, envelope: &Envelope) -> Vec<String> {
2143 envelope
2144 .label_provider_ids
2145 .iter()
2146 .filter_map(|provider_id| {
2147 self.labels
2148 .iter()
2149 .find(|label| &label.provider_id == provider_id)
2150 .map(|label| crate::ui::sidebar::humanize_label(&label.name).to_string())
2151 })
2152 .collect()
2153 }
2154
2155 fn attachment_summaries_for_envelope(&self, envelope: &Envelope) -> Vec<AttachmentSummary> {
2156 self.body_cache
2157 .get(&envelope.id)
2158 .map(|body| {
2159 body.attachments
2160 .iter()
2161 .map(|attachment| AttachmentSummary {
2162 filename: attachment.filename.clone(),
2163 size_bytes: attachment.size_bytes,
2164 })
2165 .collect()
2166 })
2167 .unwrap_or_default()
2168 }
2169
2170 fn thread_message_blocks(&self) -> Vec<ui::message_view::ThreadMessageBlock> {
2171 self.viewed_thread_messages
2172 .iter()
2173 .map(|message| ui::message_view::ThreadMessageBlock {
2174 envelope: message.clone(),
2175 body_state: self.resolve_body_view_state(message),
2176 labels: self.label_chips_for_envelope(message),
2177 attachments: self.attachment_summaries_for_envelope(message),
2178 selected: self.viewing_envelope.as_ref().map(|env| env.id.clone())
2179 == Some(message.id.clone()),
2180 has_unsubscribe: !matches!(message.unsubscribe, UnsubscribeMethod::None),
2181 signature_expanded: self.signature_expanded,
2182 })
2183 .collect()
2184 }
2185
2186 pub fn apply_local_label_refs(
2187 &mut self,
2188 message_ids: &[MessageId],
2189 add: &[String],
2190 remove: &[String],
2191 ) {
2192 let add_provider_ids = self.resolve_label_provider_ids(add);
2193 let remove_provider_ids = self.resolve_label_provider_ids(remove);
2194 for envelope in self
2195 .envelopes
2196 .iter_mut()
2197 .chain(self.all_envelopes.iter_mut())
2198 .chain(self.search_page.results.iter_mut())
2199 .chain(self.viewed_thread_messages.iter_mut())
2200 {
2201 if message_ids
2202 .iter()
2203 .any(|message_id| message_id == &envelope.id)
2204 {
2205 apply_provider_label_changes(
2206 &mut envelope.label_provider_ids,
2207 &add_provider_ids,
2208 &remove_provider_ids,
2209 );
2210 }
2211 }
2212 if let Some(ref mut envelope) = self.viewing_envelope {
2213 if message_ids
2214 .iter()
2215 .any(|message_id| message_id == &envelope.id)
2216 {
2217 apply_provider_label_changes(
2218 &mut envelope.label_provider_ids,
2219 &add_provider_ids,
2220 &remove_provider_ids,
2221 );
2222 }
2223 }
2224 }
2225
2226 pub fn apply_local_flags(&mut self, message_id: &MessageId, flags: MessageFlags) {
2227 for envelope in self
2228 .envelopes
2229 .iter_mut()
2230 .chain(self.all_envelopes.iter_mut())
2231 .chain(self.search_page.results.iter_mut())
2232 .chain(self.viewed_thread_messages.iter_mut())
2233 {
2234 if &envelope.id == message_id {
2235 envelope.flags = flags;
2236 }
2237 }
2238 if let Some(envelope) = self.viewing_envelope.as_mut() {
2239 if &envelope.id == message_id {
2240 envelope.flags = flags;
2241 }
2242 }
2243 }
2244
2245 pub fn apply_local_flags_many(&mut self, updates: &[(MessageId, MessageFlags)]) {
2246 for (message_id, flags) in updates {
2247 self.apply_local_flags(message_id, *flags);
2248 }
2249 }
2250
2251 fn apply_local_mutation_effect(&mut self, effect: &MutationEffect) {
2252 match effect {
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::RemoveFromList(_)
2268 | MutationEffect::RemoveFromListMany(_)
2269 | MutationEffect::RefreshList
2270 | MutationEffect::StatusOnly(_) => {}
2271 }
2272 }
2273
2274 fn queue_mutation(&mut self, request: Request, effect: MutationEffect, status_message: String) {
2275 self.pending_mutation_queue.push((request, effect));
2276 self.pending_mutation_count += 1;
2277 self.pending_mutation_status = Some(status_message.clone());
2278 self.status_message = Some(status_message);
2279 }
2280
2281 pub fn finish_pending_mutation(&mut self) {
2282 self.pending_mutation_count = self.pending_mutation_count.saturating_sub(1);
2283 if self.pending_mutation_count == 0 {
2284 self.pending_mutation_status = None;
2285 }
2286 }
2287
2288 pub fn show_mutation_failure(&mut self, error: &MxrError) {
2289 self.error_modal = Some(ErrorModalState {
2290 title: "Mutation Failed".into(),
2291 detail: format!(
2292 "Optimistic changes could not be applied.\nMailbox is refreshing to reconcile state.\n\n{error}"
2293 ),
2294 });
2295 self.status_message = Some(format!("Error: {error}"));
2296 }
2297
2298 pub fn refresh_mailbox_after_mutation_failure(&mut self) {
2299 self.pending_labels_refresh = true;
2300 self.pending_all_envelopes_refresh = true;
2301 self.pending_status_refresh = true;
2302 self.pending_subscriptions_refresh = true;
2303 if let Some(label_id) = self.active_label.clone() {
2304 self.pending_label_fetch = Some(label_id);
2305 }
2306 }
2307
2308 fn clear_message_view_state(&mut self) {
2309 self.pending_preview_read = None;
2310 self.viewing_envelope = None;
2311 self.viewed_thread = None;
2312 self.viewed_thread_messages.clear();
2313 self.thread_selected_index = 0;
2314 self.pending_thread_fetch = None;
2315 self.in_flight_thread_fetch = None;
2316 self.message_scroll_offset = 0;
2317 self.body_view_state = BodyViewState::Empty { preview: None };
2318 }
2319
2320 pub(crate) fn apply_removed_message_ids(&mut self, ids: &[MessageId]) {
2321 if ids.is_empty() {
2322 return;
2323 }
2324
2325 let viewing_removed = self
2326 .viewing_envelope
2327 .as_ref()
2328 .is_some_and(|envelope| ids.iter().any(|id| id == &envelope.id));
2329 let reader_was_open =
2330 self.layout_mode == LayoutMode::ThreePane && self.viewing_envelope.is_some();
2331
2332 self.envelopes
2333 .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2334 self.all_envelopes
2335 .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2336 self.search_page
2337 .results
2338 .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2339 self.viewed_thread_messages
2340 .retain(|envelope| !ids.iter().any(|id| id == &envelope.id));
2341 self.selected_set
2342 .retain(|message_id| !ids.iter().any(|id| id == message_id));
2343
2344 self.selected_index = self
2345 .selected_index
2346 .min(self.mail_row_count().saturating_sub(1));
2347 self.search_page.selected_index = self
2348 .search_page
2349 .selected_index
2350 .min(self.search_row_count().saturating_sub(1));
2351
2352 if viewing_removed {
2353 self.clear_message_view_state();
2354
2355 if reader_was_open {
2356 match self.screen {
2357 Screen::Search if self.search_row_count() > 0 => {
2358 self.ensure_search_visible();
2359 self.auto_preview_search();
2360 }
2361 Screen::Mailbox if self.mail_row_count() > 0 => {
2362 self.ensure_visible();
2363 self.auto_preview();
2364 }
2365 _ => {}
2366 }
2367 }
2368
2369 if self.viewing_envelope.is_none() && self.layout_mode == LayoutMode::ThreePane {
2370 self.layout_mode = LayoutMode::TwoPane;
2371 if self.active_pane == ActivePane::MessageView {
2372 self.active_pane = ActivePane::MailList;
2373 }
2374 }
2375 } else {
2376 if self.screen == Screen::Mailbox && self.mail_row_count() > 0 {
2377 self.ensure_visible();
2378 } else if self.screen == Screen::Search && self.search_row_count() > 0 {
2379 self.ensure_search_visible();
2380 }
2381 }
2382 }
2383
2384 fn message_flags(&self, message_id: &MessageId) -> Option<MessageFlags> {
2385 self.envelopes
2386 .iter()
2387 .chain(self.all_envelopes.iter())
2388 .chain(self.search_page.results.iter())
2389 .chain(self.viewed_thread_messages.iter())
2390 .find(|envelope| &envelope.id == message_id)
2391 .map(|envelope| envelope.flags)
2392 .or_else(|| {
2393 self.viewing_envelope
2394 .as_ref()
2395 .filter(|envelope| &envelope.id == message_id)
2396 .map(|envelope| envelope.flags)
2397 })
2398 }
2399
2400 fn flag_updates_for_ids<F>(
2401 &self,
2402 message_ids: &[MessageId],
2403 mut update: F,
2404 ) -> Vec<(MessageId, MessageFlags)>
2405 where
2406 F: FnMut(MessageFlags) -> MessageFlags,
2407 {
2408 message_ids
2409 .iter()
2410 .filter_map(|message_id| {
2411 self.message_flags(message_id)
2412 .map(|flags| (message_id.clone(), update(flags)))
2413 })
2414 .collect()
2415 }
2416
2417 fn resolve_label_provider_ids(&self, refs: &[String]) -> Vec<String> {
2418 refs.iter()
2419 .filter_map(|label_ref| {
2420 self.labels
2421 .iter()
2422 .find(|label| label.provider_id == *label_ref || label.name == *label_ref)
2423 .map(|label| label.provider_id.clone())
2424 .or_else(|| Some(label_ref.clone()))
2425 })
2426 .collect()
2427 }
2428
2429 pub fn resolve_thread_success(&mut self, thread: Thread, mut messages: Vec<Envelope>) {
2430 let thread_id = thread.id.clone();
2431 self.in_flight_thread_fetch = None;
2432 messages.sort_by_key(|message| message.date);
2433
2434 if self
2435 .viewing_envelope
2436 .as_ref()
2437 .map(|env| env.thread_id.clone())
2438 == Some(thread_id)
2439 {
2440 let focused_message_id = self.focused_thread_envelope().map(|env| env.id.clone());
2441 for message in &messages {
2442 self.queue_body_fetch(message.id.clone());
2443 }
2444 self.viewed_thread = Some(thread);
2445 self.viewed_thread_messages = messages;
2446 self.thread_selected_index = focused_message_id
2447 .and_then(|message_id| {
2448 self.viewed_thread_messages
2449 .iter()
2450 .position(|message| message.id == message_id)
2451 })
2452 .unwrap_or_else(|| self.default_thread_selected_index());
2453 self.sync_focused_thread_envelope();
2454 }
2455 }
2456
2457 pub fn resolve_thread_fetch_error(&mut self, thread_id: &mxr_core::ThreadId) {
2458 if self.in_flight_thread_fetch.as_ref() == Some(thread_id) {
2459 self.in_flight_thread_fetch = None;
2460 }
2461 }
2462
2463 fn mutation_target_ids(&self) -> Vec<MessageId> {
2465 if !self.selected_set.is_empty() {
2466 self.selected_set.iter().cloned().collect()
2467 } else if let Some(env) = self.context_envelope() {
2468 vec![env.id.clone()]
2469 } else {
2470 vec![]
2471 }
2472 }
2473
2474 fn clear_selection(&mut self) {
2475 self.selected_set.clear();
2476 self.visual_mode = false;
2477 self.visual_anchor = None;
2478 }
2479
2480 fn queue_or_confirm_bulk_action(
2481 &mut self,
2482 title: impl Into<String>,
2483 detail: impl Into<String>,
2484 request: Request,
2485 effect: MutationEffect,
2486 optimistic_effect: Option<MutationEffect>,
2487 status_message: String,
2488 count: usize,
2489 ) {
2490 if count > 1 {
2491 self.pending_bulk_confirm = Some(PendingBulkConfirm {
2492 title: title.into(),
2493 detail: detail.into(),
2494 request,
2495 effect,
2496 optimistic_effect,
2497 status_message,
2498 });
2499 } else {
2500 if let Some(effect) = optimistic_effect.as_ref() {
2501 self.apply_local_mutation_effect(effect);
2502 }
2503 self.queue_mutation(request, effect, status_message);
2504 self.clear_selection();
2505 }
2506 }
2507
2508 fn update_visual_selection(&mut self) {
2510 if self.visual_mode {
2511 if let Some(anchor) = self.visual_anchor {
2512 let (cursor, source) = if self.screen == Screen::Search {
2513 (self.search_page.selected_index, &self.search_page.results)
2514 } else {
2515 (self.selected_index, &self.envelopes)
2516 };
2517 let start = anchor.min(cursor);
2518 let end = anchor.max(cursor);
2519 self.selected_set.clear();
2520 for env in source.iter().skip(start).take(end - start + 1) {
2521 self.selected_set.insert(env.id.clone());
2522 }
2523 }
2524 }
2525 }
2526
2527 fn ensure_visible(&mut self) {
2529 let h = self.visible_height.max(1);
2530 if self.selected_index < self.scroll_offset {
2531 self.scroll_offset = self.selected_index;
2532 } else if self.selected_index >= self.scroll_offset + h {
2533 self.scroll_offset = self.selected_index + 1 - h;
2534 }
2535 self.queue_body_window();
2537 }
2538
2539 pub fn set_subscriptions(&mut self, subscriptions: Vec<SubscriptionSummary>) {
2540 let selected_id = self
2541 .selected_subscription_entry()
2542 .map(|entry| entry.summary.latest_message_id.clone());
2543 self.subscriptions_page.entries = subscriptions
2544 .into_iter()
2545 .map(|summary| SubscriptionEntry {
2546 envelope: subscription_summary_to_envelope(&summary),
2547 summary,
2548 })
2549 .collect();
2550
2551 if self.subscriptions_page.entries.is_empty() {
2552 if self.mailbox_view == MailboxView::Subscriptions {
2553 self.selected_index = 0;
2554 self.scroll_offset = 0;
2555 self.viewing_envelope = None;
2556 self.viewed_thread = None;
2557 self.viewed_thread_messages.clear();
2558 self.body_view_state = BodyViewState::Empty { preview: None };
2559 }
2560 return;
2561 }
2562
2563 if let Some(selected_id) = selected_id {
2564 if let Some(position) = self
2565 .subscriptions_page
2566 .entries
2567 .iter()
2568 .position(|entry| entry.summary.latest_message_id == selected_id)
2569 {
2570 self.selected_index = position;
2571 } else {
2572 self.selected_index = self
2573 .selected_index
2574 .min(self.subscriptions_page.entries.len().saturating_sub(1));
2575 }
2576 } else {
2577 self.selected_index = self
2578 .selected_index
2579 .min(self.subscriptions_page.entries.len().saturating_sub(1));
2580 }
2581
2582 if self.mailbox_view == MailboxView::Subscriptions {
2583 self.ensure_visible();
2584 self.auto_preview();
2585 }
2586 }
2587}
2588
2589fn apply_provider_label_changes(
2590 label_provider_ids: &mut Vec<String>,
2591 add_provider_ids: &[String],
2592 remove_provider_ids: &[String],
2593) {
2594 label_provider_ids.retain(|provider_id| {
2595 !remove_provider_ids
2596 .iter()
2597 .any(|remove| remove == provider_id)
2598 });
2599 for provider_id in add_provider_ids {
2600 if !label_provider_ids
2601 .iter()
2602 .any(|existing| existing == provider_id)
2603 {
2604 label_provider_ids.push(provider_id.clone());
2605 }
2606 }
2607}
2608
2609fn unsubscribe_method_label(method: &UnsubscribeMethod) -> &'static str {
2610 match method {
2611 UnsubscribeMethod::OneClick { .. } => "one-click",
2612 UnsubscribeMethod::Mailto { .. } => "mailto",
2613 UnsubscribeMethod::HttpLink { .. } => "browser link",
2614 UnsubscribeMethod::BodyLink { .. } => "body link",
2615 UnsubscribeMethod::None => "none",
2616 }
2617}
2618
2619fn remove_from_list_effect(ids: &[MessageId]) -> MutationEffect {
2620 if ids.len() == 1 {
2621 MutationEffect::RemoveFromList(ids[0].clone())
2622 } else {
2623 MutationEffect::RemoveFromListMany(ids.to_vec())
2624 }
2625}
2626
2627fn pluralize_messages(count: usize) -> &'static str {
2628 if count == 1 {
2629 "message"
2630 } else {
2631 "messages"
2632 }
2633}
2634
2635fn bulk_message_detail(verb: &str, count: usize) -> String {
2636 format!(
2637 "You are about to {verb} these {count} {}.",
2638 pluralize_messages(count)
2639 )
2640}
2641
2642fn subscription_summary_to_envelope(summary: &SubscriptionSummary) -> Envelope {
2643 Envelope {
2644 id: summary.latest_message_id.clone(),
2645 account_id: summary.account_id.clone(),
2646 provider_id: summary.latest_provider_id.clone(),
2647 thread_id: summary.latest_thread_id.clone(),
2648 message_id_header: None,
2649 in_reply_to: None,
2650 references: vec![],
2651 from: Address {
2652 name: summary.sender_name.clone(),
2653 email: summary.sender_email.clone(),
2654 },
2655 to: vec![],
2656 cc: vec![],
2657 bcc: vec![],
2658 subject: summary.latest_subject.clone(),
2659 date: summary.latest_date,
2660 flags: summary.latest_flags,
2661 snippet: summary.latest_snippet.clone(),
2662 has_attachments: summary.latest_has_attachments,
2663 size_bytes: summary.latest_size_bytes,
2664 unsubscribe: summary.unsubscribe.clone(),
2665 label_provider_ids: vec![],
2666 }
2667}
2668
2669fn account_summary_to_config(
2670 account: &mxr_protocol::AccountSummaryData,
2671) -> Option<mxr_protocol::AccountConfigData> {
2672 Some(mxr_protocol::AccountConfigData {
2673 key: account.key.clone()?,
2674 name: account.name.clone(),
2675 email: account.email.clone(),
2676 sync: account.sync.clone(),
2677 send: account.send.clone(),
2678 is_default: account.is_default,
2679 })
2680}
2681
2682fn account_form_from_config(account: mxr_protocol::AccountConfigData) -> AccountFormState {
2683 let mut form = AccountFormState {
2684 visible: true,
2685 key: account.key,
2686 name: account.name,
2687 email: account.email,
2688 ..AccountFormState::default()
2689 };
2690
2691 if let Some(sync) = account.sync {
2692 match sync {
2693 mxr_protocol::AccountSyncConfigData::Gmail {
2694 credential_source,
2695 client_id,
2696 client_secret,
2697 token_ref,
2698 } => {
2699 form.mode = AccountFormMode::Gmail;
2700 form.gmail_credential_source = credential_source;
2701 form.gmail_client_id = client_id;
2702 form.gmail_client_secret = client_secret.unwrap_or_default();
2703 form.gmail_token_ref = token_ref;
2704 }
2705 mxr_protocol::AccountSyncConfigData::Imap {
2706 host,
2707 port,
2708 username,
2709 password_ref,
2710 ..
2711 } => {
2712 form.mode = AccountFormMode::ImapSmtp;
2713 form.imap_host = host;
2714 form.imap_port = port.to_string();
2715 form.imap_username = username;
2716 form.imap_password_ref = password_ref;
2717 }
2718 }
2719 } else {
2720 form.mode = AccountFormMode::SmtpOnly;
2721 }
2722
2723 match account.send {
2724 Some(mxr_protocol::AccountSendConfigData::Smtp {
2725 host,
2726 port,
2727 username,
2728 password_ref,
2729 ..
2730 }) => {
2731 form.smtp_host = host;
2732 form.smtp_port = port.to_string();
2733 form.smtp_username = username;
2734 form.smtp_password_ref = password_ref;
2735 }
2736 Some(mxr_protocol::AccountSendConfigData::Gmail) => {
2737 if form.gmail_token_ref.is_empty() {
2738 form.gmail_token_ref = format!("mxr/{}-gmail", form.key);
2739 }
2740 }
2741 None => {}
2742 }
2743
2744 form
2745}
2746
2747fn account_form_field_value(form: &AccountFormState) -> Option<&str> {
2748 match (form.mode, form.active_field) {
2749 (_, 0) => None,
2750 (_, 1) => Some(form.key.as_str()),
2751 (_, 2) => Some(form.name.as_str()),
2752 (_, 3) => Some(form.email.as_str()),
2753 (AccountFormMode::Gmail, 4) => None,
2754 (AccountFormMode::Gmail, 5)
2755 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2756 {
2757 Some(form.gmail_client_id.as_str())
2758 }
2759 (AccountFormMode::Gmail, 6)
2760 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2761 {
2762 Some(form.gmail_client_secret.as_str())
2763 }
2764 (AccountFormMode::Gmail, 5 | 6) => None,
2765 (AccountFormMode::Gmail, 7) => None,
2766 (AccountFormMode::ImapSmtp, 4) => Some(form.imap_host.as_str()),
2767 (AccountFormMode::ImapSmtp, 5) => Some(form.imap_port.as_str()),
2768 (AccountFormMode::ImapSmtp, 6) => Some(form.imap_username.as_str()),
2769 (AccountFormMode::ImapSmtp, 7) => Some(form.imap_password_ref.as_str()),
2770 (AccountFormMode::ImapSmtp, 8) => Some(form.imap_password.as_str()),
2771 (AccountFormMode::ImapSmtp, 9) => Some(form.smtp_host.as_str()),
2772 (AccountFormMode::ImapSmtp, 10) => Some(form.smtp_port.as_str()),
2773 (AccountFormMode::ImapSmtp, 11) => Some(form.smtp_username.as_str()),
2774 (AccountFormMode::ImapSmtp, 12) => Some(form.smtp_password_ref.as_str()),
2775 (AccountFormMode::ImapSmtp, 13) => Some(form.smtp_password.as_str()),
2776 (AccountFormMode::SmtpOnly, 4) => Some(form.smtp_host.as_str()),
2777 (AccountFormMode::SmtpOnly, 5) => Some(form.smtp_port.as_str()),
2778 (AccountFormMode::SmtpOnly, 6) => Some(form.smtp_username.as_str()),
2779 (AccountFormMode::SmtpOnly, 7) => Some(form.smtp_password_ref.as_str()),
2780 (AccountFormMode::SmtpOnly, 8) => Some(form.smtp_password.as_str()),
2781 _ => None,
2782 }
2783}
2784
2785fn account_form_field_is_editable(form: &AccountFormState) -> bool {
2786 account_form_field_value(form).is_some()
2787}
2788
2789fn with_account_form_field_mut<F>(form: &mut AccountFormState, mut update: F)
2790where
2791 F: FnMut(&mut String),
2792{
2793 let field = match (form.mode, form.active_field) {
2794 (_, 1) => &mut form.key,
2795 (_, 2) => &mut form.name,
2796 (_, 3) => &mut form.email,
2797 (AccountFormMode::Gmail, 5)
2798 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2799 {
2800 &mut form.gmail_client_id
2801 }
2802 (AccountFormMode::Gmail, 6)
2803 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2804 {
2805 &mut form.gmail_client_secret
2806 }
2807 (AccountFormMode::ImapSmtp, 4) => &mut form.imap_host,
2808 (AccountFormMode::ImapSmtp, 5) => &mut form.imap_port,
2809 (AccountFormMode::ImapSmtp, 6) => &mut form.imap_username,
2810 (AccountFormMode::ImapSmtp, 7) => &mut form.imap_password_ref,
2811 (AccountFormMode::ImapSmtp, 8) => &mut form.imap_password,
2812 (AccountFormMode::ImapSmtp, 9) => &mut form.smtp_host,
2813 (AccountFormMode::ImapSmtp, 10) => &mut form.smtp_port,
2814 (AccountFormMode::ImapSmtp, 11) => &mut form.smtp_username,
2815 (AccountFormMode::ImapSmtp, 12) => &mut form.smtp_password_ref,
2816 (AccountFormMode::ImapSmtp, 13) => &mut form.smtp_password,
2817 (AccountFormMode::SmtpOnly, 4) => &mut form.smtp_host,
2818 (AccountFormMode::SmtpOnly, 5) => &mut form.smtp_port,
2819 (AccountFormMode::SmtpOnly, 6) => &mut form.smtp_username,
2820 (AccountFormMode::SmtpOnly, 7) => &mut form.smtp_password_ref,
2821 (AccountFormMode::SmtpOnly, 8) => &mut form.smtp_password,
2822 _ => return,
2823 };
2824 update(field);
2825}
2826
2827fn insert_account_form_char(form: &mut AccountFormState, c: char) {
2828 let cursor = form.field_cursor;
2829 with_account_form_field_mut(form, |value| {
2830 let insert_at = char_to_byte_index(value, cursor);
2831 value.insert(insert_at, c);
2832 });
2833 form.field_cursor = form.field_cursor.saturating_add(1);
2834}
2835
2836fn delete_account_form_char(form: &mut AccountFormState, backspace: bool) {
2837 let cursor = form.field_cursor;
2838 with_account_form_field_mut(form, |value| {
2839 if backspace {
2840 if cursor == 0 {
2841 return;
2842 }
2843 let start = char_to_byte_index(value, cursor - 1);
2844 let end = char_to_byte_index(value, cursor);
2845 value.replace_range(start..end, "");
2846 } else {
2847 let len = value.chars().count();
2848 if cursor >= len {
2849 return;
2850 }
2851 let start = char_to_byte_index(value, cursor);
2852 let end = char_to_byte_index(value, cursor + 1);
2853 value.replace_range(start..end, "");
2854 }
2855 });
2856 if backspace {
2857 form.field_cursor = form.field_cursor.saturating_sub(1);
2858 }
2859}
2860
2861fn char_to_byte_index(value: &str, char_index: usize) -> usize {
2862 value
2863 .char_indices()
2864 .nth(char_index)
2865 .map(|(index, _)| index)
2866 .unwrap_or(value.len())
2867}
2868
2869fn next_gmail_credential_source(
2870 current: mxr_protocol::GmailCredentialSourceData,
2871 forward: bool,
2872) -> mxr_protocol::GmailCredentialSourceData {
2873 match (current, forward) {
2874 (mxr_protocol::GmailCredentialSourceData::Bundled, true) => {
2875 mxr_protocol::GmailCredentialSourceData::Custom
2876 }
2877 (mxr_protocol::GmailCredentialSourceData::Custom, true) => {
2878 mxr_protocol::GmailCredentialSourceData::Bundled
2879 }
2880 (mxr_protocol::GmailCredentialSourceData::Bundled, false) => {
2881 mxr_protocol::GmailCredentialSourceData::Custom
2882 }
2883 (mxr_protocol::GmailCredentialSourceData::Custom, false) => {
2884 mxr_protocol::GmailCredentialSourceData::Bundled
2885 }
2886 }
2887}
2888
2889pub fn snooze_presets() -> [SnoozePreset; 4] {
2890 [
2891 SnoozePreset::TomorrowMorning,
2892 SnoozePreset::Tonight,
2893 SnoozePreset::Weekend,
2894 SnoozePreset::NextMonday,
2895 ]
2896}
2897
2898pub fn resolve_snooze_preset(
2899 preset: SnoozePreset,
2900 config: &mxr_config::SnoozeConfig,
2901) -> chrono::DateTime<chrono::Utc> {
2902 use chrono::{Datelike, Duration, Local, NaiveTime, Weekday};
2903
2904 let now = Local::now();
2905 match preset {
2906 SnoozePreset::TomorrowMorning => {
2907 let tomorrow = now.date_naive() + Duration::days(1);
2908 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2909 tomorrow
2910 .and_time(time)
2911 .and_local_timezone(now.timezone())
2912 .single()
2913 .unwrap()
2914 .with_timezone(&chrono::Utc)
2915 }
2916 SnoozePreset::Tonight => {
2917 let today = now.date_naive();
2918 let time = NaiveTime::from_hms_opt(config.evening_hour as u32, 0, 0).unwrap();
2919 let tonight = today
2920 .and_time(time)
2921 .and_local_timezone(now.timezone())
2922 .single()
2923 .unwrap()
2924 .with_timezone(&chrono::Utc);
2925 if tonight <= chrono::Utc::now() {
2926 tonight + Duration::days(1)
2927 } else {
2928 tonight
2929 }
2930 }
2931 SnoozePreset::Weekend => {
2932 let target_day = match config.weekend_day.as_str() {
2933 "sunday" => Weekday::Sun,
2934 _ => Weekday::Sat,
2935 };
2936 let days_until = (target_day.num_days_from_monday() as i64
2937 - now.weekday().num_days_from_monday() as i64
2938 + 7)
2939 % 7;
2940 let days = if days_until == 0 { 7 } else { days_until };
2941 let weekend = now.date_naive() + Duration::days(days);
2942 let time = NaiveTime::from_hms_opt(config.weekend_hour as u32, 0, 0).unwrap();
2943 weekend
2944 .and_time(time)
2945 .and_local_timezone(now.timezone())
2946 .single()
2947 .unwrap()
2948 .with_timezone(&chrono::Utc)
2949 }
2950 SnoozePreset::NextMonday => {
2951 let days_until_monday = (Weekday::Mon.num_days_from_monday() as i64
2952 - now.weekday().num_days_from_monday() as i64
2953 + 7)
2954 % 7;
2955 let days = if days_until_monday == 0 {
2956 7
2957 } else {
2958 days_until_monday
2959 };
2960 let monday = now.date_naive() + Duration::days(days);
2961 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2962 monday
2963 .and_time(time)
2964 .and_local_timezone(now.timezone())
2965 .single()
2966 .unwrap()
2967 .with_timezone(&chrono::Utc)
2968 }
2969 }
2970}
2971
2972#[cfg(test)]
2973mod tests {
2974 use super::*;
2975 use chrono::TimeZone;
2976
2977 fn test_envelope(
2978 thread_id: mxr_core::ThreadId,
2979 subject: &str,
2980 date: chrono::DateTime<chrono::Utc>,
2981 ) -> Envelope {
2982 Envelope {
2983 id: MessageId::new(),
2984 account_id: AccountId::new(),
2985 provider_id: subject.to_string(),
2986 thread_id,
2987 message_id_header: None,
2988 in_reply_to: None,
2989 references: vec![],
2990 from: Address {
2991 name: Some("Alice".to_string()),
2992 email: "alice@example.com".to_string(),
2993 },
2994 to: vec![],
2995 cc: vec![],
2996 bcc: vec![],
2997 subject: subject.to_string(),
2998 date,
2999 flags: MessageFlags::empty(),
3000 snippet: String::new(),
3001 has_attachments: false,
3002 size_bytes: 0,
3003 unsubscribe: UnsubscribeMethod::None,
3004 label_provider_ids: vec![],
3005 }
3006 }
3007
3008 #[test]
3009 fn build_mail_list_rows_ignores_impossible_future_thread_dates() {
3010 let thread_id = mxr_core::ThreadId::new();
3011 let poisoned = test_envelope(
3012 thread_id.clone(),
3013 "Poisoned future",
3014 chrono::Utc
3015 .timestamp_opt(236_816_444_325, 0)
3016 .single()
3017 .unwrap(),
3018 );
3019 let recent = test_envelope(thread_id, "Real recent", chrono::Utc::now());
3020
3021 let rows = App::build_mail_list_rows(&[poisoned, recent.clone()], MailListMode::Threads);
3022
3023 assert_eq!(rows.len(), 1);
3024 assert_eq!(rows[0].representative.subject, recent.subject);
3025 }
3026}