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