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};
19use ratatui::prelude::*;
20use std::collections::{HashMap, HashSet};
21
22#[derive(Debug, Clone)]
23pub enum MutationEffect {
24 RemoveFromList(MessageId),
25 RemoveFromListMany(Vec<MessageId>),
26 UpdateFlags {
27 message_id: MessageId,
28 flags: MessageFlags,
29 },
30 ModifyLabels {
31 message_ids: Vec<MessageId>,
32 add: Vec<String>,
33 remove: Vec<String>,
34 status: String,
35 },
36 RefreshList,
37 StatusOnly(String),
38}
39
40pub struct PendingSend {
42 pub fm: mxr_compose::frontmatter::ComposeFrontmatter,
43 pub body: String,
44 pub draft_path: std::path::PathBuf,
45 pub allow_send: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ComposeAction {
50 New,
51 NewWithTo(String),
52 EditDraft(std::path::PathBuf),
53 Reply { message_id: MessageId },
54 ReplyAll { message_id: MessageId },
55 Forward { message_id: MessageId },
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ActivePane {
60 Sidebar,
61 MailList,
62 MessageView,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum MailListMode {
67 Threads,
68 Messages,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum MailboxView {
73 Messages,
74 Subscriptions,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Screen {
79 Mailbox,
80 Search,
81 Rules,
82 Diagnostics,
83 Accounts,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SidebarSection {
88 Labels,
89 SavedSearches,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum LayoutMode {
94 TwoPane,
95 ThreePane,
96 FullScreen,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum BodySource {
101 Plain,
102 Html,
103 Snippet,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum BodyViewState {
108 Loading {
109 preview: Option<String>,
110 },
111 Ready {
112 raw: String,
113 rendered: String,
114 source: BodySource,
115 },
116 Empty {
117 preview: Option<String>,
118 },
119 Error {
120 message: String,
121 preview: Option<String>,
122 },
123}
124
125#[derive(Debug, Clone)]
126pub struct MailListRow {
127 pub thread_id: mxr_core::ThreadId,
128 pub representative: Envelope,
129 pub message_count: usize,
130 pub unread_count: usize,
131}
132
133#[derive(Debug, Clone)]
134pub struct SubscriptionEntry {
135 pub summary: SubscriptionSummary,
136 pub envelope: Envelope,
137}
138
139#[derive(Debug, Clone)]
140pub enum SidebarItem {
141 AllMail,
142 Subscriptions,
143 Label(Label),
144 SavedSearch(mxr_core::SavedSearch),
145}
146
147#[derive(Debug, Clone, Default)]
148pub struct SubscriptionsPageState {
149 pub entries: Vec<SubscriptionEntry>,
150}
151
152#[derive(Debug, Clone, Default)]
153pub struct SearchPageState {
154 pub query: String,
155 pub editing: bool,
156 pub results: Vec<Envelope>,
157 pub scores: HashMap<MessageId, f32>,
158 pub selected_index: usize,
159 pub scroll_offset: usize,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum RulesPanel {
164 Details,
165 History,
166 DryRun,
167 Form,
168}
169
170#[derive(Debug, Clone, Default)]
171pub struct RuleFormState {
172 pub visible: bool,
173 pub existing_rule: Option<String>,
174 pub name: String,
175 pub condition: String,
176 pub action: String,
177 pub priority: String,
178 pub enabled: bool,
179 pub active_field: usize,
180}
181
182#[derive(Debug, Clone)]
183pub struct RulesPageState {
184 pub rules: Vec<serde_json::Value>,
185 pub selected_index: usize,
186 pub detail: Option<serde_json::Value>,
187 pub history: Vec<serde_json::Value>,
188 pub dry_run: Vec<serde_json::Value>,
189 pub panel: RulesPanel,
190 pub status: Option<String>,
191 pub refresh_pending: bool,
192 pub form: RuleFormState,
193}
194
195impl Default for RulesPageState {
196 fn default() -> Self {
197 Self {
198 rules: Vec::new(),
199 selected_index: 0,
200 detail: None,
201 history: Vec::new(),
202 dry_run: Vec::new(),
203 panel: RulesPanel::Details,
204 status: None,
205 refresh_pending: false,
206 form: RuleFormState {
207 enabled: true,
208 priority: "100".to_string(),
209 ..RuleFormState::default()
210 },
211 }
212 }
213}
214
215#[derive(Debug, Clone, Default)]
216pub struct DiagnosticsPageState {
217 pub uptime_secs: Option<u64>,
218 pub daemon_pid: Option<u32>,
219 pub accounts: Vec<String>,
220 pub total_messages: Option<u32>,
221 pub sync_statuses: Vec<mxr_protocol::AccountSyncStatus>,
222 pub doctor: Option<mxr_protocol::DoctorReport>,
223 pub events: Vec<mxr_protocol::EventLogEntry>,
224 pub logs: Vec<String>,
225 pub status: Option<String>,
226 pub refresh_pending: bool,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum AccountFormMode {
231 Gmail,
232 ImapSmtp,
233 SmtpOnly,
234}
235
236#[derive(Debug, Clone)]
237pub struct AccountFormState {
238 pub visible: bool,
239 pub mode: AccountFormMode,
240 pub pending_mode_switch: Option<AccountFormMode>,
241 pub key: String,
242 pub name: String,
243 pub email: String,
244 pub gmail_credential_source: mxr_protocol::GmailCredentialSourceData,
245 pub gmail_client_id: String,
246 pub gmail_client_secret: String,
247 pub gmail_token_ref: String,
248 pub gmail_authorized: bool,
249 pub imap_host: String,
250 pub imap_port: String,
251 pub imap_username: String,
252 pub imap_password_ref: String,
253 pub imap_password: String,
254 pub smtp_host: String,
255 pub smtp_port: String,
256 pub smtp_username: String,
257 pub smtp_password_ref: String,
258 pub smtp_password: String,
259 pub active_field: usize,
260 pub editing_field: bool,
261 pub field_cursor: usize,
262 pub last_result: Option<mxr_protocol::AccountOperationResult>,
263}
264
265impl Default for AccountFormState {
266 fn default() -> Self {
267 Self {
268 visible: false,
269 mode: AccountFormMode::Gmail,
270 pending_mode_switch: None,
271 key: String::new(),
272 name: String::new(),
273 email: String::new(),
274 gmail_credential_source: mxr_protocol::GmailCredentialSourceData::Bundled,
275 gmail_client_id: String::new(),
276 gmail_client_secret: String::new(),
277 gmail_token_ref: String::new(),
278 gmail_authorized: false,
279 imap_host: String::new(),
280 imap_port: "993".into(),
281 imap_username: String::new(),
282 imap_password_ref: String::new(),
283 imap_password: String::new(),
284 smtp_host: String::new(),
285 smtp_port: "587".into(),
286 smtp_username: String::new(),
287 smtp_password_ref: String::new(),
288 smtp_password: String::new(),
289 active_field: 0,
290 editing_field: false,
291 field_cursor: 0,
292 last_result: None,
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default)]
298pub struct AccountsPageState {
299 pub accounts: Vec<mxr_protocol::AccountSummaryData>,
300 pub selected_index: usize,
301 pub status: Option<String>,
302 pub last_result: Option<mxr_protocol::AccountOperationResult>,
303 pub refresh_pending: bool,
304 pub onboarding_required: bool,
305 pub onboarding_modal_open: bool,
306 pub form: AccountFormState,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum AttachmentOperation {
311 Open,
312 Download,
313}
314
315#[derive(Debug, Clone, Default)]
316pub struct AttachmentPanelState {
317 pub visible: bool,
318 pub message_id: Option<MessageId>,
319 pub attachments: Vec<AttachmentMeta>,
320 pub selected_index: usize,
321 pub status: Option<String>,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum SnoozePreset {
326 TomorrowMorning,
327 Tonight,
328 Weekend,
329 NextMonday,
330}
331
332#[derive(Debug, Clone, Default)]
333pub struct SnoozePanelState {
334 pub visible: bool,
335 pub selected_index: usize,
336}
337
338#[derive(Debug, Clone)]
339pub struct PendingAttachmentAction {
340 pub message_id: MessageId,
341 pub attachment_id: AttachmentId,
342 pub operation: AttachmentOperation,
343}
344
345#[derive(Debug, Clone)]
346pub struct PendingBulkConfirm {
347 pub title: String,
348 pub detail: String,
349 pub request: Request,
350 pub effect: MutationEffect,
351 pub status_message: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct AttachmentSummary {
356 pub filename: String,
357 pub size_bytes: u64,
358}
359
360#[derive(Debug, Clone)]
361pub struct PendingUnsubscribeConfirm {
362 pub message_id: MessageId,
363 pub account_id: AccountId,
364 pub sender_email: String,
365 pub method_label: String,
366 pub archive_message_ids: Vec<MessageId>,
367}
368
369#[derive(Debug, Clone)]
370pub struct PendingUnsubscribeAction {
371 pub message_id: MessageId,
372 pub archive_message_ids: Vec<MessageId>,
373 pub sender_email: String,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377enum SidebarGroup {
378 SystemLabels,
379 UserLabels,
380 SavedSearches,
381}
382
383impl BodyViewState {
384 pub fn display_text(&self) -> Option<&str> {
385 match self {
386 Self::Ready { rendered, .. } => Some(rendered.as_str()),
387 Self::Loading { preview } => preview.as_deref(),
388 Self::Empty { preview } => preview.as_deref(),
389 Self::Error { preview, .. } => preview.as_deref(),
390 }
391 }
392}
393
394pub struct App {
395 pub theme: Theme,
396 pub envelopes: Vec<Envelope>,
397 pub all_envelopes: Vec<Envelope>,
398 pub mailbox_view: MailboxView,
399 pub labels: Vec<Label>,
400 pub screen: Screen,
401 pub mail_list_mode: MailListMode,
402 pub selected_index: usize,
403 pub scroll_offset: usize,
404 pub active_pane: ActivePane,
405 pub should_quit: bool,
406 pub layout_mode: LayoutMode,
407 pub search_bar: SearchBar,
408 pub search_page: SearchPageState,
409 pub command_palette: CommandPalette,
410 pub body_view_state: BodyViewState,
411 pub viewing_envelope: Option<Envelope>,
412 pub viewed_thread: Option<Thread>,
413 pub viewed_thread_messages: Vec<Envelope>,
414 pub thread_selected_index: usize,
415 pub message_scroll_offset: u16,
416 pub last_sync_status: Option<String>,
417 pub visible_height: usize,
418 pub body_cache: HashMap<MessageId, MessageBody>,
419 pub queued_body_fetches: Vec<MessageId>,
420 pub in_flight_body_requests: HashSet<MessageId>,
421 pub pending_thread_fetch: Option<mxr_core::ThreadId>,
422 pub in_flight_thread_fetch: Option<mxr_core::ThreadId>,
423 pub pending_search: Option<(String, SearchMode)>,
424 pub search_active: bool,
425 pub pending_rule_detail: Option<String>,
426 pub pending_rule_history: Option<String>,
427 pub pending_rule_dry_run: Option<String>,
428 pub pending_rule_delete: Option<String>,
429 pub pending_rule_upsert: Option<serde_json::Value>,
430 pub pending_rule_form_load: Option<String>,
431 pub pending_rule_form_save: bool,
432 pub pending_bug_report: bool,
433 pub pending_account_save: Option<mxr_protocol::AccountConfigData>,
434 pub pending_account_test: Option<mxr_protocol::AccountConfigData>,
435 pub pending_account_authorize: Option<(mxr_protocol::AccountConfigData, bool)>,
436 pub pending_account_set_default: Option<String>,
437 pub sidebar_selected: usize,
438 pub sidebar_section: SidebarSection,
439 pub help_modal_open: bool,
440 pub help_scroll_offset: u16,
441 pub saved_searches: Vec<mxr_core::SavedSearch>,
442 pub subscriptions_page: SubscriptionsPageState,
443 pub rules_page: RulesPageState,
444 pub diagnostics_page: DiagnosticsPageState,
445 pub accounts_page: AccountsPageState,
446 pub active_label: Option<mxr_core::LabelId>,
447 pub pending_label_fetch: Option<mxr_core::LabelId>,
448 pub pending_active_label: Option<mxr_core::LabelId>,
449 pub pending_labels_refresh: bool,
450 pub pending_all_envelopes_refresh: bool,
451 pub pending_subscriptions_refresh: bool,
452 pub desired_system_mailbox: Option<String>,
453 pub status_message: Option<String>,
454 pub pending_mutation_queue: Vec<(Request, MutationEffect)>,
455 pub pending_compose: Option<ComposeAction>,
456 pub pending_send_confirm: Option<PendingSend>,
457 pub pending_bulk_confirm: Option<PendingBulkConfirm>,
458 pub pending_unsubscribe_confirm: Option<PendingUnsubscribeConfirm>,
459 pub pending_unsubscribe_action: Option<PendingUnsubscribeAction>,
460 pub reader_mode: bool,
461 pub signature_expanded: bool,
462 pub label_picker: LabelPicker,
463 pub compose_picker: ComposePicker,
464 pub attachment_panel: AttachmentPanelState,
465 pub snooze_panel: SnoozePanelState,
466 pub pending_attachment_action: Option<PendingAttachmentAction>,
467 pub selected_set: HashSet<MessageId>,
468 pub visual_mode: bool,
469 pub visual_anchor: Option<usize>,
470 pub pending_export_thread: Option<mxr_core::id::ThreadId>,
471 pub snooze_config: mxr_config::SnoozeConfig,
472 pub sidebar_system_expanded: bool,
473 pub sidebar_user_expanded: bool,
474 pub sidebar_saved_searches_expanded: bool,
475 pending_label_action: Option<(LabelPickerMode, String)>,
476 pub url_modal: Option<ui::url_modal::UrlModalState>,
477 input: InputHandler,
478}
479
480impl Default for App {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486impl App {
487 pub fn new() -> Self {
488 Self::from_render_and_snooze(
489 &RenderConfig::default(),
490 &mxr_config::SnoozeConfig::default(),
491 )
492 }
493
494 pub fn from_config(config: &mxr_config::MxrConfig) -> Self {
495 let mut app = Self::from_render_and_snooze(&config.render, &config.snooze);
496 app.theme = Theme::from_spec(&config.appearance.theme);
497 if config.accounts.is_empty() {
498 app.enter_account_setup_onboarding();
499 }
500 app
501 }
502
503 pub fn from_render_config(render: &RenderConfig) -> Self {
504 Self::from_render_and_snooze(render, &mxr_config::SnoozeConfig::default())
505 }
506
507 fn from_render_and_snooze(
508 render: &RenderConfig,
509 snooze_config: &mxr_config::SnoozeConfig,
510 ) -> Self {
511 Self {
512 theme: Theme::default(),
513 envelopes: Vec::new(),
514 all_envelopes: Vec::new(),
515 mailbox_view: MailboxView::Messages,
516 labels: Vec::new(),
517 screen: Screen::Mailbox,
518 mail_list_mode: MailListMode::Threads,
519 selected_index: 0,
520 scroll_offset: 0,
521 active_pane: ActivePane::MailList,
522 should_quit: false,
523 layout_mode: LayoutMode::TwoPane,
524 search_bar: SearchBar::default(),
525 search_page: SearchPageState::default(),
526 command_palette: CommandPalette::default(),
527 body_view_state: BodyViewState::Empty { preview: None },
528 viewing_envelope: None,
529 viewed_thread: None,
530 viewed_thread_messages: Vec::new(),
531 thread_selected_index: 0,
532 message_scroll_offset: 0,
533 last_sync_status: None,
534 visible_height: 20,
535 body_cache: HashMap::new(),
536 queued_body_fetches: Vec::new(),
537 in_flight_body_requests: HashSet::new(),
538 pending_thread_fetch: None,
539 in_flight_thread_fetch: None,
540 pending_search: None,
541 search_active: false,
542 pending_rule_detail: None,
543 pending_rule_history: None,
544 pending_rule_dry_run: None,
545 pending_rule_delete: None,
546 pending_rule_upsert: None,
547 pending_rule_form_load: None,
548 pending_rule_form_save: false,
549 pending_bug_report: false,
550 pending_account_save: None,
551 pending_account_test: None,
552 pending_account_authorize: None,
553 pending_account_set_default: None,
554 sidebar_selected: 0,
555 sidebar_section: SidebarSection::Labels,
556 help_modal_open: false,
557 help_scroll_offset: 0,
558 saved_searches: Vec::new(),
559 subscriptions_page: SubscriptionsPageState::default(),
560 rules_page: RulesPageState::default(),
561 diagnostics_page: DiagnosticsPageState::default(),
562 accounts_page: AccountsPageState::default(),
563 active_label: None,
564 pending_label_fetch: None,
565 pending_active_label: None,
566 pending_labels_refresh: false,
567 pending_all_envelopes_refresh: false,
568 pending_subscriptions_refresh: false,
569 desired_system_mailbox: None,
570 status_message: None,
571 pending_mutation_queue: Vec::new(),
572 pending_compose: None,
573 pending_send_confirm: None,
574 pending_bulk_confirm: None,
575 pending_unsubscribe_confirm: None,
576 pending_unsubscribe_action: None,
577 reader_mode: render.reader_mode,
578 signature_expanded: false,
579 label_picker: LabelPicker::default(),
580 compose_picker: ComposePicker::default(),
581 attachment_panel: AttachmentPanelState::default(),
582 snooze_panel: SnoozePanelState::default(),
583 pending_attachment_action: None,
584 selected_set: HashSet::new(),
585 visual_mode: false,
586 visual_anchor: None,
587 pending_export_thread: None,
588 snooze_config: snooze_config.clone(),
589 sidebar_system_expanded: true,
590 sidebar_user_expanded: true,
591 sidebar_saved_searches_expanded: true,
592 pending_label_action: None,
593 url_modal: None,
594 input: InputHandler::new(),
595 }
596 }
597
598 pub fn selected_envelope(&self) -> Option<&Envelope> {
599 if self.mailbox_view == MailboxView::Subscriptions {
600 return self
601 .subscriptions_page
602 .entries
603 .get(self.selected_index)
604 .map(|entry| &entry.envelope);
605 }
606
607 match self.mail_list_mode {
608 MailListMode::Messages => self.envelopes.get(self.selected_index),
609 MailListMode::Threads => self.selected_mail_row().and_then(|row| {
610 self.envelopes
611 .iter()
612 .find(|env| env.id == row.representative.id)
613 }),
614 }
615 }
616
617 pub fn mail_list_rows(&self) -> Vec<MailListRow> {
618 Self::build_mail_list_rows(&self.envelopes, self.mail_list_mode)
619 }
620
621 pub fn search_mail_list_rows(&self) -> Vec<MailListRow> {
622 Self::build_mail_list_rows(&self.search_page.results, self.mail_list_mode)
623 }
624
625 pub fn selected_mail_row(&self) -> Option<MailListRow> {
626 if self.mailbox_view == MailboxView::Subscriptions {
627 return None;
628 }
629 self.mail_list_rows().get(self.selected_index).cloned()
630 }
631
632 pub fn selected_subscription_entry(&self) -> Option<&SubscriptionEntry> {
633 self.subscriptions_page.entries.get(self.selected_index)
634 }
635
636 pub fn focused_thread_envelope(&self) -> Option<&Envelope> {
637 self.viewed_thread_messages.get(self.thread_selected_index)
638 }
639
640 pub fn sidebar_items(&self) -> Vec<SidebarItem> {
641 let mut items = vec![SidebarItem::AllMail, SidebarItem::Subscriptions];
642 let mut system_labels = Vec::new();
643 let mut user_labels = Vec::new();
644 for label in self.visible_labels() {
645 if label.kind == LabelKind::System {
646 system_labels.push(label.clone());
647 } else {
648 user_labels.push(label.clone());
649 }
650 }
651 if self.sidebar_system_expanded {
652 items.extend(system_labels.into_iter().map(SidebarItem::Label));
653 }
654 if self.sidebar_user_expanded {
655 items.extend(user_labels.into_iter().map(SidebarItem::Label));
656 }
657 if self.sidebar_saved_searches_expanded {
658 items.extend(
659 self.saved_searches
660 .iter()
661 .cloned()
662 .map(SidebarItem::SavedSearch),
663 );
664 }
665 items
666 }
667
668 pub fn selected_sidebar_item(&self) -> Option<SidebarItem> {
669 self.sidebar_items().get(self.sidebar_selected).cloned()
670 }
671
672 pub fn selected_search_envelope(&self) -> Option<&Envelope> {
673 match self.mail_list_mode {
674 MailListMode::Messages => self
675 .search_page
676 .results
677 .get(self.search_page.selected_index),
678 MailListMode::Threads => self
679 .search_mail_list_rows()
680 .get(self.search_page.selected_index)
681 .and_then(|row| {
682 self.search_page
683 .results
684 .iter()
685 .find(|env| env.id == row.representative.id)
686 }),
687 }
688 }
689
690 pub fn selected_rule(&self) -> Option<&serde_json::Value> {
691 self.rules_page.rules.get(self.rules_page.selected_index)
692 }
693
694 pub fn selected_account(&self) -> Option<&mxr_protocol::AccountSummaryData> {
695 self.accounts_page
696 .accounts
697 .get(self.accounts_page.selected_index)
698 }
699
700 pub fn enter_account_setup_onboarding(&mut self) {
701 self.screen = Screen::Accounts;
702 self.accounts_page.refresh_pending = true;
703 self.accounts_page.onboarding_required = true;
704 self.accounts_page.onboarding_modal_open = true;
705 self.active_label = None;
706 self.pending_active_label = None;
707 self.pending_label_fetch = None;
708 self.desired_system_mailbox = None;
709 }
710
711 fn complete_account_setup_onboarding(&mut self) {
712 self.accounts_page.onboarding_modal_open = false;
713 self.apply(Action::OpenAccountFormNew);
714 }
715
716 fn selected_account_config(&self) -> Option<mxr_protocol::AccountConfigData> {
717 self.selected_account().and_then(account_summary_to_config)
718 }
719
720 fn account_form_field_count(&self) -> usize {
721 match self.accounts_page.form.mode {
722 AccountFormMode::Gmail => {
723 if self.accounts_page.form.gmail_credential_source
724 == mxr_protocol::GmailCredentialSourceData::Custom
725 {
726 8
727 } else {
728 6
729 }
730 }
731 AccountFormMode::ImapSmtp => 14,
732 AccountFormMode::SmtpOnly => 9,
733 }
734 }
735
736 fn account_form_data(&self, is_default: bool) -> mxr_protocol::AccountConfigData {
737 let form = &self.accounts_page.form;
738 let key = form.key.trim().to_string();
739 let name = if form.name.trim().is_empty() {
740 key.clone()
741 } else {
742 form.name.trim().to_string()
743 };
744 let email = form.email.trim().to_string();
745 let imap_username = if form.imap_username.trim().is_empty() {
746 email.clone()
747 } else {
748 form.imap_username.trim().to_string()
749 };
750 let smtp_username = if form.smtp_username.trim().is_empty() {
751 email.clone()
752 } else {
753 form.smtp_username.trim().to_string()
754 };
755 let gmail_token_ref = if form.gmail_token_ref.trim().is_empty() {
756 format!("mxr/{key}-gmail")
757 } else {
758 form.gmail_token_ref.trim().to_string()
759 };
760 let sync = match form.mode {
761 AccountFormMode::Gmail => Some(mxr_protocol::AccountSyncConfigData::Gmail {
762 credential_source: form.gmail_credential_source.clone(),
763 client_id: form.gmail_client_id.trim().to_string(),
764 client_secret: if form.gmail_client_secret.trim().is_empty() {
765 None
766 } else {
767 Some(form.gmail_client_secret.clone())
768 },
769 token_ref: gmail_token_ref,
770 }),
771 AccountFormMode::ImapSmtp => Some(mxr_protocol::AccountSyncConfigData::Imap {
772 host: form.imap_host.trim().to_string(),
773 port: form.imap_port.parse().unwrap_or(993),
774 username: imap_username,
775 password_ref: form.imap_password_ref.trim().to_string(),
776 password: if form.imap_password.is_empty() {
777 None
778 } else {
779 Some(form.imap_password.clone())
780 },
781 use_tls: true,
782 }),
783 AccountFormMode::SmtpOnly => None,
784 };
785 let send = match form.mode {
786 AccountFormMode::Gmail => Some(mxr_protocol::AccountSendConfigData::Gmail),
787 AccountFormMode::ImapSmtp | AccountFormMode::SmtpOnly => {
788 Some(mxr_protocol::AccountSendConfigData::Smtp {
789 host: form.smtp_host.trim().to_string(),
790 port: form.smtp_port.parse().unwrap_or(587),
791 username: smtp_username,
792 password_ref: form.smtp_password_ref.trim().to_string(),
793 password: if form.smtp_password.is_empty() {
794 None
795 } else {
796 Some(form.smtp_password.clone())
797 },
798 use_tls: true,
799 })
800 }
801 };
802 mxr_protocol::AccountConfigData {
803 key,
804 name,
805 email,
806 sync,
807 send,
808 is_default,
809 }
810 }
811
812 fn next_account_form_mode(&self, forward: bool) -> AccountFormMode {
813 match (self.accounts_page.form.mode, forward) {
814 (AccountFormMode::Gmail, true) => AccountFormMode::ImapSmtp,
815 (AccountFormMode::ImapSmtp, true) => AccountFormMode::SmtpOnly,
816 (AccountFormMode::SmtpOnly, true) => AccountFormMode::Gmail,
817 (AccountFormMode::Gmail, false) => AccountFormMode::SmtpOnly,
818 (AccountFormMode::ImapSmtp, false) => AccountFormMode::Gmail,
819 (AccountFormMode::SmtpOnly, false) => AccountFormMode::ImapSmtp,
820 }
821 }
822
823 fn account_form_has_meaningful_input(&self) -> bool {
824 let form = &self.accounts_page.form;
825 [
826 form.key.trim(),
827 form.name.trim(),
828 form.email.trim(),
829 form.gmail_client_id.trim(),
830 form.gmail_client_secret.trim(),
831 form.imap_host.trim(),
832 form.imap_username.trim(),
833 form.imap_password_ref.trim(),
834 form.imap_password.trim(),
835 form.smtp_host.trim(),
836 form.smtp_username.trim(),
837 form.smtp_password_ref.trim(),
838 form.smtp_password.trim(),
839 ]
840 .iter()
841 .any(|value| !value.is_empty())
842 }
843
844 fn apply_account_form_mode(&mut self, mode: AccountFormMode) {
845 self.accounts_page.form.mode = mode;
846 self.accounts_page.form.pending_mode_switch = None;
847 self.accounts_page.form.active_field = self
848 .accounts_page
849 .form
850 .active_field
851 .min(self.account_form_field_count().saturating_sub(1));
852 self.accounts_page.form.editing_field = false;
853 self.accounts_page.form.field_cursor = 0;
854 self.refresh_account_form_derived_fields();
855 }
856
857 fn request_account_form_mode_change(&mut self, forward: bool) {
858 let next_mode = self.next_account_form_mode(forward);
859 if next_mode == self.accounts_page.form.mode {
860 return;
861 }
862 if self.account_form_has_meaningful_input() {
863 self.accounts_page.form.pending_mode_switch = Some(next_mode);
864 } else {
865 self.apply_account_form_mode(next_mode);
866 }
867 }
868
869 fn refresh_account_form_derived_fields(&mut self) {
870 if matches!(self.accounts_page.form.mode, AccountFormMode::Gmail) {
871 let key = self.accounts_page.form.key.trim();
872 let token_ref = if key.is_empty() {
873 String::new()
874 } else {
875 format!("mxr/{key}-gmail")
876 };
877 self.accounts_page.form.gmail_token_ref = token_ref;
878 }
879 }
880
881 fn mail_row_count(&self) -> usize {
882 if self.mailbox_view == MailboxView::Subscriptions {
883 return self.subscriptions_page.entries.len();
884 }
885 self.mail_list_rows().len()
886 }
887
888 fn search_row_count(&self) -> usize {
889 self.search_mail_list_rows().len()
890 }
891
892 fn build_mail_list_rows(envelopes: &[Envelope], mode: MailListMode) -> Vec<MailListRow> {
893 match mode {
894 MailListMode::Messages => envelopes
895 .iter()
896 .map(|envelope| MailListRow {
897 thread_id: envelope.thread_id.clone(),
898 representative: envelope.clone(),
899 message_count: 1,
900 unread_count: usize::from(!envelope.flags.contains(MessageFlags::READ)),
901 })
902 .collect(),
903 MailListMode::Threads => {
904 let mut order: Vec<mxr_core::ThreadId> = Vec::new();
905 let mut rows: HashMap<mxr_core::ThreadId, MailListRow> = HashMap::new();
906 for envelope in envelopes {
907 let entry = rows.entry(envelope.thread_id.clone()).or_insert_with(|| {
908 order.push(envelope.thread_id.clone());
909 MailListRow {
910 thread_id: envelope.thread_id.clone(),
911 representative: envelope.clone(),
912 message_count: 0,
913 unread_count: 0,
914 }
915 });
916 entry.message_count += 1;
917 if !envelope.flags.contains(MessageFlags::READ) {
918 entry.unread_count += 1;
919 }
920 if envelope.date > entry.representative.date {
921 entry.representative = envelope.clone();
922 }
923 }
924 order
925 .into_iter()
926 .filter_map(|thread_id| rows.remove(&thread_id))
927 .collect()
928 }
929 }
930 }
931
932 fn context_envelope(&self) -> Option<&Envelope> {
934 if self.screen == Screen::Search {
935 return self
936 .focused_thread_envelope()
937 .or(self.viewing_envelope.as_ref())
938 .or_else(|| self.selected_search_envelope());
939 }
940
941 self.focused_thread_envelope()
942 .or(self.viewing_envelope.as_ref())
943 .or_else(|| self.selected_envelope())
944 }
945
946 pub async fn load(&mut self, client: &mut Client) -> Result<(), MxrError> {
947 self.all_envelopes = client.list_envelopes(5000, 0).await?;
948 self.envelopes = self.all_mail_envelopes();
949 self.labels = client.list_labels().await?;
950 self.saved_searches = client.list_saved_searches().await.unwrap_or_default();
951 self.set_subscriptions(client.list_subscriptions(500).await.unwrap_or_default());
952 self.queue_body_window();
954 Ok(())
955 }
956
957 pub fn input_pending(&self) -> bool {
958 self.input.is_pending()
959 }
960
961 pub fn ordered_visible_labels(&self) -> Vec<&Label> {
962 let mut system: Vec<&Label> = self
963 .labels
964 .iter()
965 .filter(|l| !crate::ui::sidebar::should_hide_label(&l.name))
966 .filter(|l| l.kind == mxr_core::types::LabelKind::System)
967 .filter(|l| {
968 crate::ui::sidebar::is_primary_system_label(&l.name)
969 || l.total_count > 0
970 || l.unread_count > 0
971 })
972 .collect();
973 system.sort_by_key(|l| crate::ui::sidebar::system_label_order(&l.name));
974
975 let mut user: Vec<&Label> = self
976 .labels
977 .iter()
978 .filter(|l| !crate::ui::sidebar::should_hide_label(&l.name))
979 .filter(|l| l.kind != mxr_core::types::LabelKind::System)
980 .collect();
981 user.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
982
983 let mut result = system;
984 result.extend(user);
985 result
986 }
987
988 pub fn visible_label_count(&self) -> usize {
990 self.ordered_visible_labels().len()
991 }
992
993 pub fn visible_labels(&self) -> Vec<&Label> {
995 self.ordered_visible_labels()
996 }
997
998 fn sidebar_move_down(&mut self) {
999 if self.sidebar_selected + 1 < self.sidebar_items().len() {
1000 self.sidebar_selected += 1;
1001 }
1002 self.sync_sidebar_section();
1003 }
1004
1005 fn sidebar_move_up(&mut self) {
1006 self.sidebar_selected = self.sidebar_selected.saturating_sub(1);
1007 self.sync_sidebar_section();
1008 }
1009
1010 fn sidebar_select(&mut self) -> Option<Action> {
1011 match self.selected_sidebar_item() {
1012 Some(SidebarItem::AllMail) => Some(Action::GoToAllMail),
1013 Some(SidebarItem::Subscriptions) => Some(Action::OpenSubscriptions),
1014 Some(SidebarItem::Label(label)) => Some(Action::SelectLabel(label.id)),
1015 Some(SidebarItem::SavedSearch(search)) => {
1016 Some(Action::SelectSavedSearch(search.query, search.search_mode))
1017 }
1018 None => None,
1019 }
1020 }
1021
1022 fn sync_sidebar_section(&mut self) {
1023 self.sidebar_section = match self.selected_sidebar_item() {
1024 Some(SidebarItem::SavedSearch(_)) => SidebarSection::SavedSearches,
1025 _ => SidebarSection::Labels,
1026 };
1027 }
1028
1029 fn current_sidebar_group(&self) -> SidebarGroup {
1030 match self.selected_sidebar_item() {
1031 Some(SidebarItem::SavedSearch(_)) => SidebarGroup::SavedSearches,
1032 Some(SidebarItem::Label(label)) if label.kind == LabelKind::System => {
1033 SidebarGroup::SystemLabels
1034 }
1035 Some(SidebarItem::Label(_)) => SidebarGroup::UserLabels,
1036 Some(SidebarItem::AllMail) | Some(SidebarItem::Subscriptions) | None => {
1037 SidebarGroup::SystemLabels
1038 }
1039 }
1040 }
1041
1042 fn collapse_current_sidebar_section(&mut self) {
1043 match self.current_sidebar_group() {
1044 SidebarGroup::SystemLabels => self.sidebar_system_expanded = false,
1045 SidebarGroup::UserLabels => self.sidebar_user_expanded = false,
1046 SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = false,
1047 }
1048 self.sidebar_selected = self
1049 .sidebar_selected
1050 .min(self.sidebar_items().len().saturating_sub(1));
1051 self.sync_sidebar_section();
1052 }
1053
1054 fn expand_current_sidebar_section(&mut self) {
1055 match self.current_sidebar_group() {
1056 SidebarGroup::SystemLabels => self.sidebar_system_expanded = true,
1057 SidebarGroup::UserLabels => self.sidebar_user_expanded = true,
1058 SidebarGroup::SavedSearches => self.sidebar_saved_searches_expanded = true,
1059 }
1060 self.sidebar_selected = self
1061 .sidebar_selected
1062 .min(self.sidebar_items().len().saturating_sub(1));
1063 self.sync_sidebar_section();
1064 }
1065
1066 fn trigger_live_search(&mut self) {
1069 let query = self.search_bar.query.to_lowercase();
1070 if query.is_empty() {
1071 self.envelopes = self.all_mail_envelopes();
1072 self.search_active = false;
1073 } else {
1074 let query_words: Vec<&str> = query.split_whitespace().collect();
1075 self.envelopes = self
1078 .all_envelopes
1079 .iter()
1080 .filter(|e| !e.flags.contains(MessageFlags::TRASH))
1081 .filter(|e| {
1082 let haystack = format!(
1083 "{} {} {} {}",
1084 e.subject,
1085 e.from.email,
1086 e.from.name.as_deref().unwrap_or(""),
1087 e.snippet
1088 )
1089 .to_lowercase();
1090 query_words.iter().all(|qw| {
1091 haystack.split_whitespace().any(|hw| hw.starts_with(qw))
1092 || haystack.contains(qw)
1093 })
1094 })
1095 .cloned()
1096 .collect();
1097 self.search_active = true;
1098 self.pending_search = Some((self.search_bar.query.clone(), self.search_bar.mode));
1100 }
1101 self.selected_index = 0;
1102 self.scroll_offset = 0;
1103 }
1104
1105 pub fn mail_list_title(&self) -> String {
1107 if self.mailbox_view == MailboxView::Subscriptions {
1108 return format!("Subscriptions ({})", self.subscriptions_page.entries.len());
1109 }
1110
1111 let list_name = match self.mail_list_mode {
1112 MailListMode::Threads => "Threads",
1113 MailListMode::Messages => "Messages",
1114 };
1115 let list_count = self.mail_row_count();
1116 if self.search_active {
1117 format!("Search: {} ({list_count})", self.search_bar.query)
1118 } else if let Some(label_id) = self
1119 .pending_active_label
1120 .as_ref()
1121 .or(self.active_label.as_ref())
1122 {
1123 if let Some(label) = self.labels.iter().find(|l| &l.id == label_id) {
1124 let name = crate::ui::sidebar::humanize_label(&label.name);
1125 format!("{name} {list_name} ({list_count})")
1126 } else {
1127 format!("{list_name} ({list_count})")
1128 }
1129 } else {
1130 format!("All Mail {list_name} ({list_count})")
1131 }
1132 }
1133
1134 fn all_mail_envelopes(&self) -> Vec<Envelope> {
1135 self.all_envelopes
1136 .iter()
1137 .filter(|envelope| !envelope.flags.contains(MessageFlags::TRASH))
1138 .cloned()
1139 .collect()
1140 }
1141
1142 pub fn resolve_desired_system_mailbox(&mut self) {
1143 let Some(target) = self.desired_system_mailbox.as_deref() else {
1144 return;
1145 };
1146 if self.pending_active_label.is_some() || self.active_label.is_some() {
1147 return;
1148 }
1149 if let Some(label_id) = self
1150 .labels
1151 .iter()
1152 .find(|label| label.name.eq_ignore_ascii_case(target))
1153 .map(|label| label.id.clone())
1154 {
1155 self.apply(Action::SelectLabel(label_id));
1156 }
1157 }
1158
1159 fn auto_preview(&mut self) {
1161 if self.mailbox_view == MailboxView::Subscriptions {
1162 if let Some(entry) = self.selected_subscription_entry().cloned() {
1163 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&entry.envelope.id) {
1164 self.open_envelope(entry.envelope);
1165 }
1166 } else {
1167 self.viewing_envelope = None;
1168 self.viewed_thread = None;
1169 self.viewed_thread_messages.clear();
1170 self.body_view_state = BodyViewState::Empty { preview: None };
1171 }
1172 return;
1173 }
1174
1175 if self.layout_mode == LayoutMode::ThreePane {
1176 if let Some(row) = self.selected_mail_row() {
1177 if self.viewing_envelope.as_ref().map(|e| &e.id) != Some(&row.representative.id) {
1178 self.open_envelope(row.representative);
1179 }
1180 }
1181 }
1182 }
1183
1184 pub fn auto_preview_search(&mut self) {
1185 if let Some(env) = self.selected_search_envelope().cloned() {
1186 if self
1187 .viewing_envelope
1188 .as_ref()
1189 .map(|current| current.id.clone())
1190 != Some(env.id.clone())
1191 {
1192 self.open_envelope(env);
1193 }
1194 }
1195 }
1196
1197 fn ensure_search_visible(&mut self) {
1198 let h = self.visible_height.max(1);
1199 if self.search_page.selected_index < self.search_page.scroll_offset {
1200 self.search_page.scroll_offset = self.search_page.selected_index;
1201 } else if self.search_page.selected_index >= self.search_page.scroll_offset + h {
1202 self.search_page.scroll_offset = self.search_page.selected_index + 1 - h;
1203 }
1204 }
1205
1206 pub fn queue_body_window(&mut self) {
1209 const BUFFER: usize = 50;
1210 let source_envelopes: Vec<Envelope> = if self.mailbox_view == MailboxView::Subscriptions {
1211 self.subscriptions_page
1212 .entries
1213 .iter()
1214 .map(|entry| entry.envelope.clone())
1215 .collect()
1216 } else {
1217 self.envelopes.clone()
1218 };
1219 let len = source_envelopes.len();
1220 if len == 0 {
1221 return;
1222 }
1223 let start = self.selected_index.saturating_sub(BUFFER / 2);
1224 let end = (self.selected_index + BUFFER / 2).min(len);
1225 let ids: Vec<MessageId> = source_envelopes[start..end]
1226 .iter()
1227 .map(|e| e.id.clone())
1228 .collect();
1229 for id in ids {
1230 self.queue_body_fetch(id);
1231 }
1232 }
1233
1234 fn open_envelope(&mut self, env: Envelope) {
1235 self.close_attachment_panel();
1236 self.signature_expanded = false;
1237 self.viewed_thread = None;
1238 self.viewed_thread_messages = self.optimistic_thread_messages(&env);
1239 self.thread_selected_index = self.default_thread_selected_index();
1240 self.viewing_envelope = self.focused_thread_envelope().cloned();
1241 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1242 self.mark_envelope_read_on_open(&viewing_envelope);
1243 }
1244 for message in self.viewed_thread_messages.clone() {
1245 self.queue_body_fetch(message.id);
1246 }
1247 self.queue_thread_fetch(env.thread_id.clone());
1248 self.message_scroll_offset = 0;
1249 self.ensure_current_body_state();
1250 }
1251
1252 fn optimistic_thread_messages(&self, env: &Envelope) -> Vec<Envelope> {
1253 let mut messages: Vec<Envelope> = self
1254 .all_envelopes
1255 .iter()
1256 .filter(|candidate| candidate.thread_id == env.thread_id)
1257 .cloned()
1258 .collect();
1259 if messages.is_empty() {
1260 messages.push(env.clone());
1261 }
1262 messages.sort_by_key(|message| message.date);
1263 messages
1264 }
1265
1266 fn default_thread_selected_index(&self) -> usize {
1267 self.viewed_thread_messages
1268 .iter()
1269 .rposition(|message| !message.flags.contains(MessageFlags::READ))
1270 .or_else(|| self.viewed_thread_messages.len().checked_sub(1))
1271 .unwrap_or(0)
1272 }
1273
1274 fn sync_focused_thread_envelope(&mut self) {
1275 self.close_attachment_panel();
1276 self.viewing_envelope = self.focused_thread_envelope().cloned();
1277 if let Some(viewing_envelope) = self.viewing_envelope.clone() {
1278 self.mark_envelope_read_on_open(&viewing_envelope);
1279 }
1280 self.message_scroll_offset = 0;
1281 self.ensure_current_body_state();
1282 }
1283
1284 fn mark_envelope_read_on_open(&mut self, envelope: &Envelope) {
1285 if envelope.flags.contains(MessageFlags::READ)
1286 || self.has_pending_set_read(&envelope.id, true)
1287 {
1288 return;
1289 }
1290
1291 let mut flags = envelope.flags;
1292 flags.insert(MessageFlags::READ);
1293 self.apply_local_flags(&envelope.id, flags);
1294 self.pending_mutation_queue.push((
1295 Request::Mutation(MutationCommand::SetRead {
1296 message_ids: vec![envelope.id.clone()],
1297 read: true,
1298 }),
1299 MutationEffect::UpdateFlags {
1300 message_id: envelope.id.clone(),
1301 flags,
1302 },
1303 ));
1304 }
1305
1306 fn has_pending_set_read(&self, message_id: &MessageId, read: bool) -> bool {
1307 self.pending_mutation_queue.iter().any(|(request, _)| {
1308 matches!(
1309 request,
1310 Request::Mutation(MutationCommand::SetRead { message_ids, read: queued_read })
1311 if *queued_read == read
1312 && message_ids.len() == 1
1313 && message_ids[0] == *message_id
1314 )
1315 })
1316 }
1317
1318 fn move_thread_focus_down(&mut self) {
1319 if self.thread_selected_index + 1 < self.viewed_thread_messages.len() {
1320 self.thread_selected_index += 1;
1321 self.sync_focused_thread_envelope();
1322 }
1323 }
1324
1325 fn move_thread_focus_up(&mut self) {
1326 if self.thread_selected_index > 0 {
1327 self.thread_selected_index -= 1;
1328 self.sync_focused_thread_envelope();
1329 }
1330 }
1331
1332 fn ensure_current_body_state(&mut self) {
1333 if let Some(env) = self.viewing_envelope.clone() {
1334 if !self.body_cache.contains_key(&env.id) {
1335 self.queue_body_fetch(env.id.clone());
1336 }
1337 self.body_view_state = self.resolve_body_view_state(&env);
1338 } else {
1339 self.body_view_state = BodyViewState::Empty { preview: None };
1340 }
1341 }
1342
1343 fn queue_body_fetch(&mut self, message_id: MessageId) {
1344 if self.body_cache.contains_key(&message_id)
1345 || self.in_flight_body_requests.contains(&message_id)
1346 || self.queued_body_fetches.contains(&message_id)
1347 {
1348 return;
1349 }
1350
1351 self.in_flight_body_requests.insert(message_id.clone());
1352 self.queued_body_fetches.push(message_id);
1353 }
1354
1355 fn queue_thread_fetch(&mut self, thread_id: mxr_core::ThreadId) {
1356 if self.pending_thread_fetch.as_ref() == Some(&thread_id)
1357 || self.in_flight_thread_fetch.as_ref() == Some(&thread_id)
1358 {
1359 return;
1360 }
1361 self.pending_thread_fetch = Some(thread_id);
1362 }
1363
1364 fn envelope_preview(envelope: &Envelope) -> Option<String> {
1365 let snippet = envelope.snippet.trim();
1366 if snippet.is_empty() {
1367 None
1368 } else {
1369 Some(envelope.snippet.clone())
1370 }
1371 }
1372
1373 fn render_body(raw: &str, source: BodySource, reader_mode: bool) -> String {
1374 if !reader_mode {
1375 return raw.to_string();
1376 }
1377
1378 let config = mxr_reader::ReaderConfig::default();
1379 match source {
1380 BodySource::Plain => mxr_reader::clean(Some(raw), None, &config).content,
1381 BodySource::Html => mxr_reader::clean(None, Some(raw), &config).content,
1382 BodySource::Snippet => raw.to_string(),
1383 }
1384 }
1385
1386 fn resolve_body_view_state(&self, envelope: &Envelope) -> BodyViewState {
1387 let preview = Self::envelope_preview(envelope);
1388
1389 if let Some(body) = self.body_cache.get(&envelope.id) {
1390 if let Some(raw) = body.text_plain.clone() {
1391 let rendered = Self::render_body(&raw, BodySource::Plain, self.reader_mode);
1392 return BodyViewState::Ready {
1393 raw,
1394 rendered,
1395 source: BodySource::Plain,
1396 };
1397 }
1398
1399 if let Some(raw) = body.text_html.clone() {
1400 let rendered = Self::render_body(&raw, BodySource::Html, self.reader_mode);
1401 return BodyViewState::Ready {
1402 raw,
1403 rendered,
1404 source: BodySource::Html,
1405 };
1406 }
1407
1408 return BodyViewState::Empty { preview };
1409 }
1410
1411 if self.in_flight_body_requests.contains(&envelope.id) {
1412 BodyViewState::Loading { preview }
1413 } else {
1414 BodyViewState::Empty { preview }
1415 }
1416 }
1417
1418 pub fn resolve_body_success(&mut self, body: MessageBody) {
1419 let message_id = body.message_id.clone();
1420 self.in_flight_body_requests.remove(&message_id);
1421 self.body_cache.insert(message_id.clone(), body);
1422
1423 if self.viewing_envelope.as_ref().map(|env| env.id.clone()) == Some(message_id) {
1424 self.ensure_current_body_state();
1425 }
1426 }
1427
1428 pub fn resolve_body_fetch_error(&mut self, message_id: &MessageId, message: String) {
1429 self.in_flight_body_requests.remove(message_id);
1430
1431 if let Some(env) = self
1432 .viewing_envelope
1433 .as_ref()
1434 .filter(|env| &env.id == message_id)
1435 {
1436 self.body_view_state = BodyViewState::Error {
1437 message,
1438 preview: Self::envelope_preview(env),
1439 };
1440 }
1441 }
1442
1443 pub fn current_viewing_body(&self) -> Option<&MessageBody> {
1444 self.viewing_envelope
1445 .as_ref()
1446 .and_then(|env| self.body_cache.get(&env.id))
1447 }
1448
1449 pub fn selected_attachment(&self) -> Option<&AttachmentMeta> {
1450 self.attachment_panel
1451 .attachments
1452 .get(self.attachment_panel.selected_index)
1453 }
1454
1455 pub fn open_attachment_panel(&mut self) {
1456 let Some(message_id) = self.viewing_envelope.as_ref().map(|env| env.id.clone()) else {
1457 self.status_message = Some("No message selected".into());
1458 return;
1459 };
1460 let Some(attachments) = self
1461 .current_viewing_body()
1462 .map(|body| body.attachments.clone())
1463 else {
1464 self.status_message = Some("No message body loaded".into());
1465 return;
1466 };
1467 if attachments.is_empty() {
1468 self.status_message = Some("No attachments".into());
1469 return;
1470 }
1471
1472 self.attachment_panel.visible = true;
1473 self.attachment_panel.message_id = Some(message_id);
1474 self.attachment_panel.attachments = attachments;
1475 self.attachment_panel.selected_index = 0;
1476 self.attachment_panel.status = None;
1477 }
1478
1479 pub fn open_url_modal(&mut self) {
1480 let body = self.current_viewing_body();
1481 let Some(body) = body else {
1482 self.status_message = Some("No message body loaded".into());
1483 return;
1484 };
1485 let text_plain = body.text_plain.as_deref();
1486 let text_html = body.text_html.as_deref();
1487 let urls = ui::url_modal::extract_urls(text_plain, text_html);
1488 if urls.is_empty() {
1489 self.status_message = Some("No links found".into());
1490 return;
1491 }
1492 self.url_modal = Some(ui::url_modal::UrlModalState::new(urls));
1493 }
1494
1495 pub fn close_attachment_panel(&mut self) {
1496 self.attachment_panel = AttachmentPanelState::default();
1497 self.pending_attachment_action = None;
1498 }
1499
1500 pub fn queue_attachment_action(&mut self, operation: AttachmentOperation) {
1501 let Some(message_id) = self.attachment_panel.message_id.clone() else {
1502 return;
1503 };
1504 let Some(attachment) = self.selected_attachment().cloned() else {
1505 return;
1506 };
1507
1508 self.attachment_panel.status = Some(match operation {
1509 AttachmentOperation::Open => format!("Opening {}...", attachment.filename),
1510 AttachmentOperation::Download => format!("Downloading {}...", attachment.filename),
1511 });
1512 self.pending_attachment_action = Some(PendingAttachmentAction {
1513 message_id,
1514 attachment_id: attachment.id,
1515 operation,
1516 });
1517 }
1518
1519 pub fn resolve_attachment_file(&mut self, file: &mxr_protocol::AttachmentFile) {
1520 let path = std::path::PathBuf::from(&file.path);
1521 for attachment in &mut self.attachment_panel.attachments {
1522 if attachment.id == file.attachment_id {
1523 attachment.local_path = Some(path.clone());
1524 }
1525 }
1526 for body in self.body_cache.values_mut() {
1527 for attachment in &mut body.attachments {
1528 if attachment.id == file.attachment_id {
1529 attachment.local_path = Some(path.clone());
1530 }
1531 }
1532 }
1533 }
1534
1535 fn label_chips_for_envelope(&self, envelope: &Envelope) -> Vec<String> {
1536 envelope
1537 .label_provider_ids
1538 .iter()
1539 .filter_map(|provider_id| {
1540 self.labels
1541 .iter()
1542 .find(|label| &label.provider_id == provider_id)
1543 .map(|label| crate::ui::sidebar::humanize_label(&label.name).to_string())
1544 })
1545 .collect()
1546 }
1547
1548 fn attachment_summaries_for_envelope(&self, envelope: &Envelope) -> Vec<AttachmentSummary> {
1549 self.body_cache
1550 .get(&envelope.id)
1551 .map(|body| {
1552 body.attachments
1553 .iter()
1554 .map(|attachment| AttachmentSummary {
1555 filename: attachment.filename.clone(),
1556 size_bytes: attachment.size_bytes,
1557 })
1558 .collect()
1559 })
1560 .unwrap_or_default()
1561 }
1562
1563 fn thread_message_blocks(&self) -> Vec<ui::message_view::ThreadMessageBlock> {
1564 self.viewed_thread_messages
1565 .iter()
1566 .map(|message| ui::message_view::ThreadMessageBlock {
1567 envelope: message.clone(),
1568 body_state: self.resolve_body_view_state(message),
1569 labels: self.label_chips_for_envelope(message),
1570 attachments: self.attachment_summaries_for_envelope(message),
1571 selected: self.viewing_envelope.as_ref().map(|env| env.id.clone())
1572 == Some(message.id.clone()),
1573 has_unsubscribe: !matches!(message.unsubscribe, UnsubscribeMethod::None),
1574 signature_expanded: self.signature_expanded,
1575 })
1576 .collect()
1577 }
1578
1579 pub fn apply_local_label_refs(
1580 &mut self,
1581 message_ids: &[MessageId],
1582 add: &[String],
1583 remove: &[String],
1584 ) {
1585 let add_provider_ids = self.resolve_label_provider_ids(add);
1586 let remove_provider_ids = self.resolve_label_provider_ids(remove);
1587 for envelope in self
1588 .envelopes
1589 .iter_mut()
1590 .chain(self.all_envelopes.iter_mut())
1591 .chain(self.search_page.results.iter_mut())
1592 .chain(self.viewed_thread_messages.iter_mut())
1593 {
1594 if message_ids
1595 .iter()
1596 .any(|message_id| message_id == &envelope.id)
1597 {
1598 apply_provider_label_changes(
1599 &mut envelope.label_provider_ids,
1600 &add_provider_ids,
1601 &remove_provider_ids,
1602 );
1603 }
1604 }
1605 if let Some(ref mut envelope) = self.viewing_envelope {
1606 if message_ids
1607 .iter()
1608 .any(|message_id| message_id == &envelope.id)
1609 {
1610 apply_provider_label_changes(
1611 &mut envelope.label_provider_ids,
1612 &add_provider_ids,
1613 &remove_provider_ids,
1614 );
1615 }
1616 }
1617 }
1618
1619 pub fn apply_local_flags(&mut self, message_id: &MessageId, flags: MessageFlags) {
1620 for envelope in self
1621 .envelopes
1622 .iter_mut()
1623 .chain(self.all_envelopes.iter_mut())
1624 .chain(self.search_page.results.iter_mut())
1625 .chain(self.viewed_thread_messages.iter_mut())
1626 {
1627 if &envelope.id == message_id {
1628 envelope.flags = flags;
1629 }
1630 }
1631 if let Some(envelope) = self.viewing_envelope.as_mut() {
1632 if &envelope.id == message_id {
1633 envelope.flags = flags;
1634 }
1635 }
1636 }
1637
1638 fn resolve_label_provider_ids(&self, refs: &[String]) -> Vec<String> {
1639 refs.iter()
1640 .filter_map(|label_ref| {
1641 self.labels
1642 .iter()
1643 .find(|label| label.provider_id == *label_ref || label.name == *label_ref)
1644 .map(|label| label.provider_id.clone())
1645 .or_else(|| Some(label_ref.clone()))
1646 })
1647 .collect()
1648 }
1649
1650 pub fn resolve_thread_success(&mut self, thread: Thread, mut messages: Vec<Envelope>) {
1651 let thread_id = thread.id.clone();
1652 self.in_flight_thread_fetch = None;
1653 messages.sort_by_key(|message| message.date);
1654
1655 if self
1656 .viewing_envelope
1657 .as_ref()
1658 .map(|env| env.thread_id.clone())
1659 == Some(thread_id)
1660 {
1661 let focused_message_id = self.focused_thread_envelope().map(|env| env.id.clone());
1662 for message in &messages {
1663 self.queue_body_fetch(message.id.clone());
1664 }
1665 self.viewed_thread = Some(thread);
1666 self.viewed_thread_messages = messages;
1667 self.thread_selected_index = focused_message_id
1668 .and_then(|message_id| {
1669 self.viewed_thread_messages
1670 .iter()
1671 .position(|message| message.id == message_id)
1672 })
1673 .unwrap_or_else(|| self.default_thread_selected_index());
1674 self.sync_focused_thread_envelope();
1675 }
1676 }
1677
1678 pub fn resolve_thread_fetch_error(&mut self, thread_id: &mxr_core::ThreadId) {
1679 if self.in_flight_thread_fetch.as_ref() == Some(thread_id) {
1680 self.in_flight_thread_fetch = None;
1681 }
1682 }
1683
1684 fn mutation_target_ids(&self) -> Vec<MessageId> {
1686 if !self.selected_set.is_empty() {
1687 self.selected_set.iter().cloned().collect()
1688 } else if let Some(env) = self.context_envelope() {
1689 vec![env.id.clone()]
1690 } else {
1691 vec![]
1692 }
1693 }
1694
1695 fn clear_selection(&mut self) {
1696 self.selected_set.clear();
1697 self.visual_mode = false;
1698 self.visual_anchor = None;
1699 }
1700
1701 fn queue_or_confirm_bulk_action(
1702 &mut self,
1703 title: impl Into<String>,
1704 detail: impl Into<String>,
1705 request: Request,
1706 effect: MutationEffect,
1707 status_message: String,
1708 count: usize,
1709 ) {
1710 if count > 1 {
1711 self.pending_bulk_confirm = Some(PendingBulkConfirm {
1712 title: title.into(),
1713 detail: detail.into(),
1714 request,
1715 effect,
1716 status_message,
1717 });
1718 } else {
1719 self.pending_mutation_queue.push((request, effect));
1720 self.status_message = Some(status_message);
1721 self.clear_selection();
1722 }
1723 }
1724
1725 fn update_visual_selection(&mut self) {
1727 if self.visual_mode {
1728 if let Some(anchor) = self.visual_anchor {
1729 let start = anchor.min(self.selected_index);
1730 let end = anchor.max(self.selected_index);
1731 self.selected_set.clear();
1732 for env in self.envelopes.iter().skip(start).take(end - start + 1) {
1733 self.selected_set.insert(env.id.clone());
1734 }
1735 }
1736 }
1737 }
1738
1739 fn ensure_visible(&mut self) {
1741 let h = self.visible_height.max(1);
1742 if self.selected_index < self.scroll_offset {
1743 self.scroll_offset = self.selected_index;
1744 } else if self.selected_index >= self.scroll_offset + h {
1745 self.scroll_offset = self.selected_index + 1 - h;
1746 }
1747 self.queue_body_window();
1749 }
1750
1751 pub fn set_subscriptions(&mut self, subscriptions: Vec<SubscriptionSummary>) {
1752 let selected_id = self
1753 .selected_subscription_entry()
1754 .map(|entry| entry.summary.latest_message_id.clone());
1755 self.subscriptions_page.entries = subscriptions
1756 .into_iter()
1757 .map(|summary| SubscriptionEntry {
1758 envelope: subscription_summary_to_envelope(&summary),
1759 summary,
1760 })
1761 .collect();
1762
1763 if self.subscriptions_page.entries.is_empty() {
1764 if self.mailbox_view == MailboxView::Subscriptions {
1765 self.selected_index = 0;
1766 self.scroll_offset = 0;
1767 self.viewing_envelope = None;
1768 self.viewed_thread = None;
1769 self.viewed_thread_messages.clear();
1770 self.body_view_state = BodyViewState::Empty { preview: None };
1771 }
1772 return;
1773 }
1774
1775 if let Some(selected_id) = selected_id {
1776 if let Some(position) = self
1777 .subscriptions_page
1778 .entries
1779 .iter()
1780 .position(|entry| entry.summary.latest_message_id == selected_id)
1781 {
1782 self.selected_index = position;
1783 } else {
1784 self.selected_index = self
1785 .selected_index
1786 .min(self.subscriptions_page.entries.len().saturating_sub(1));
1787 }
1788 } else {
1789 self.selected_index = self
1790 .selected_index
1791 .min(self.subscriptions_page.entries.len().saturating_sub(1));
1792 }
1793
1794 if self.mailbox_view == MailboxView::Subscriptions {
1795 self.ensure_visible();
1796 self.auto_preview();
1797 }
1798 }
1799}
1800
1801fn apply_provider_label_changes(
1802 label_provider_ids: &mut Vec<String>,
1803 add_provider_ids: &[String],
1804 remove_provider_ids: &[String],
1805) {
1806 label_provider_ids.retain(|provider_id| {
1807 !remove_provider_ids
1808 .iter()
1809 .any(|remove| remove == provider_id)
1810 });
1811 for provider_id in add_provider_ids {
1812 if !label_provider_ids
1813 .iter()
1814 .any(|existing| existing == provider_id)
1815 {
1816 label_provider_ids.push(provider_id.clone());
1817 }
1818 }
1819}
1820
1821fn unsubscribe_method_label(method: &UnsubscribeMethod) -> &'static str {
1822 match method {
1823 UnsubscribeMethod::OneClick { .. } => "one-click",
1824 UnsubscribeMethod::Mailto { .. } => "mailto",
1825 UnsubscribeMethod::HttpLink { .. } => "browser link",
1826 UnsubscribeMethod::BodyLink { .. } => "body link",
1827 UnsubscribeMethod::None => "none",
1828 }
1829}
1830
1831fn remove_from_list_effect(ids: &[MessageId]) -> MutationEffect {
1832 if ids.len() == 1 {
1833 MutationEffect::RemoveFromList(ids[0].clone())
1834 } else {
1835 MutationEffect::RemoveFromListMany(ids.to_vec())
1836 }
1837}
1838
1839fn pluralize_messages(count: usize) -> &'static str {
1840 if count == 1 {
1841 "message"
1842 } else {
1843 "messages"
1844 }
1845}
1846
1847fn bulk_message_detail(verb: &str, count: usize) -> String {
1848 format!(
1849 "You are about to {verb} these {count} {}.",
1850 pluralize_messages(count)
1851 )
1852}
1853
1854fn subscription_summary_to_envelope(summary: &SubscriptionSummary) -> Envelope {
1855 Envelope {
1856 id: summary.latest_message_id.clone(),
1857 account_id: summary.account_id.clone(),
1858 provider_id: summary.latest_provider_id.clone(),
1859 thread_id: summary.latest_thread_id.clone(),
1860 message_id_header: None,
1861 in_reply_to: None,
1862 references: vec![],
1863 from: Address {
1864 name: summary.sender_name.clone(),
1865 email: summary.sender_email.clone(),
1866 },
1867 to: vec![],
1868 cc: vec![],
1869 bcc: vec![],
1870 subject: summary.latest_subject.clone(),
1871 date: summary.latest_date,
1872 flags: summary.latest_flags,
1873 snippet: summary.latest_snippet.clone(),
1874 has_attachments: summary.latest_has_attachments,
1875 size_bytes: summary.latest_size_bytes,
1876 unsubscribe: summary.unsubscribe.clone(),
1877 label_provider_ids: vec![],
1878 }
1879}
1880
1881fn account_summary_to_config(
1882 account: &mxr_protocol::AccountSummaryData,
1883) -> Option<mxr_protocol::AccountConfigData> {
1884 Some(mxr_protocol::AccountConfigData {
1885 key: account.key.clone()?,
1886 name: account.name.clone(),
1887 email: account.email.clone(),
1888 sync: account.sync.clone(),
1889 send: account.send.clone(),
1890 is_default: account.is_default,
1891 })
1892}
1893
1894fn account_form_from_config(account: mxr_protocol::AccountConfigData) -> AccountFormState {
1895 let mut form = AccountFormState {
1896 visible: true,
1897 key: account.key,
1898 name: account.name,
1899 email: account.email,
1900 ..AccountFormState::default()
1901 };
1902
1903 if let Some(sync) = account.sync {
1904 match sync {
1905 mxr_protocol::AccountSyncConfigData::Gmail {
1906 credential_source,
1907 client_id,
1908 client_secret,
1909 token_ref,
1910 } => {
1911 form.mode = AccountFormMode::Gmail;
1912 form.gmail_credential_source = credential_source;
1913 form.gmail_client_id = client_id;
1914 form.gmail_client_secret = client_secret.unwrap_or_default();
1915 form.gmail_token_ref = token_ref;
1916 }
1917 mxr_protocol::AccountSyncConfigData::Imap {
1918 host,
1919 port,
1920 username,
1921 password_ref,
1922 ..
1923 } => {
1924 form.mode = AccountFormMode::ImapSmtp;
1925 form.imap_host = host;
1926 form.imap_port = port.to_string();
1927 form.imap_username = username;
1928 form.imap_password_ref = password_ref;
1929 }
1930 }
1931 } else {
1932 form.mode = AccountFormMode::SmtpOnly;
1933 }
1934
1935 match account.send {
1936 Some(mxr_protocol::AccountSendConfigData::Smtp {
1937 host,
1938 port,
1939 username,
1940 password_ref,
1941 ..
1942 }) => {
1943 form.smtp_host = host;
1944 form.smtp_port = port.to_string();
1945 form.smtp_username = username;
1946 form.smtp_password_ref = password_ref;
1947 }
1948 Some(mxr_protocol::AccountSendConfigData::Gmail) => {
1949 if form.gmail_token_ref.is_empty() {
1950 form.gmail_token_ref = format!("mxr/{}-gmail", form.key);
1951 }
1952 }
1953 None => {}
1954 }
1955
1956 form
1957}
1958
1959fn account_form_field_value(form: &AccountFormState) -> Option<&str> {
1960 match (form.mode, form.active_field) {
1961 (_, 0) => None,
1962 (_, 1) => Some(form.key.as_str()),
1963 (_, 2) => Some(form.name.as_str()),
1964 (_, 3) => Some(form.email.as_str()),
1965 (AccountFormMode::Gmail, 4) => None,
1966 (AccountFormMode::Gmail, 5)
1967 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
1968 {
1969 Some(form.gmail_client_id.as_str())
1970 }
1971 (AccountFormMode::Gmail, 6)
1972 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
1973 {
1974 Some(form.gmail_client_secret.as_str())
1975 }
1976 (AccountFormMode::Gmail, 5 | 6) => None,
1977 (AccountFormMode::Gmail, 7) => None,
1978 (AccountFormMode::ImapSmtp, 4) => Some(form.imap_host.as_str()),
1979 (AccountFormMode::ImapSmtp, 5) => Some(form.imap_port.as_str()),
1980 (AccountFormMode::ImapSmtp, 6) => Some(form.imap_username.as_str()),
1981 (AccountFormMode::ImapSmtp, 7) => Some(form.imap_password_ref.as_str()),
1982 (AccountFormMode::ImapSmtp, 8) => Some(form.imap_password.as_str()),
1983 (AccountFormMode::ImapSmtp, 9) => Some(form.smtp_host.as_str()),
1984 (AccountFormMode::ImapSmtp, 10) => Some(form.smtp_port.as_str()),
1985 (AccountFormMode::ImapSmtp, 11) => Some(form.smtp_username.as_str()),
1986 (AccountFormMode::ImapSmtp, 12) => Some(form.smtp_password_ref.as_str()),
1987 (AccountFormMode::ImapSmtp, 13) => Some(form.smtp_password.as_str()),
1988 (AccountFormMode::SmtpOnly, 4) => Some(form.smtp_host.as_str()),
1989 (AccountFormMode::SmtpOnly, 5) => Some(form.smtp_port.as_str()),
1990 (AccountFormMode::SmtpOnly, 6) => Some(form.smtp_username.as_str()),
1991 (AccountFormMode::SmtpOnly, 7) => Some(form.smtp_password_ref.as_str()),
1992 (AccountFormMode::SmtpOnly, 8) => Some(form.smtp_password.as_str()),
1993 _ => None,
1994 }
1995}
1996
1997fn account_form_field_is_editable(form: &AccountFormState) -> bool {
1998 account_form_field_value(form).is_some()
1999}
2000
2001fn with_account_form_field_mut<F>(form: &mut AccountFormState, mut update: F)
2002where
2003 F: FnMut(&mut String),
2004{
2005 let field = match (form.mode, form.active_field) {
2006 (_, 1) => &mut form.key,
2007 (_, 2) => &mut form.name,
2008 (_, 3) => &mut form.email,
2009 (AccountFormMode::Gmail, 5)
2010 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2011 {
2012 &mut form.gmail_client_id
2013 }
2014 (AccountFormMode::Gmail, 6)
2015 if form.gmail_credential_source == mxr_protocol::GmailCredentialSourceData::Custom =>
2016 {
2017 &mut form.gmail_client_secret
2018 }
2019 (AccountFormMode::ImapSmtp, 4) => &mut form.imap_host,
2020 (AccountFormMode::ImapSmtp, 5) => &mut form.imap_port,
2021 (AccountFormMode::ImapSmtp, 6) => &mut form.imap_username,
2022 (AccountFormMode::ImapSmtp, 7) => &mut form.imap_password_ref,
2023 (AccountFormMode::ImapSmtp, 8) => &mut form.imap_password,
2024 (AccountFormMode::ImapSmtp, 9) => &mut form.smtp_host,
2025 (AccountFormMode::ImapSmtp, 10) => &mut form.smtp_port,
2026 (AccountFormMode::ImapSmtp, 11) => &mut form.smtp_username,
2027 (AccountFormMode::ImapSmtp, 12) => &mut form.smtp_password_ref,
2028 (AccountFormMode::ImapSmtp, 13) => &mut form.smtp_password,
2029 (AccountFormMode::SmtpOnly, 4) => &mut form.smtp_host,
2030 (AccountFormMode::SmtpOnly, 5) => &mut form.smtp_port,
2031 (AccountFormMode::SmtpOnly, 6) => &mut form.smtp_username,
2032 (AccountFormMode::SmtpOnly, 7) => &mut form.smtp_password_ref,
2033 (AccountFormMode::SmtpOnly, 8) => &mut form.smtp_password,
2034 _ => return,
2035 };
2036 update(field);
2037}
2038
2039fn insert_account_form_char(form: &mut AccountFormState, c: char) {
2040 let cursor = form.field_cursor;
2041 with_account_form_field_mut(form, |value| {
2042 let insert_at = char_to_byte_index(value, cursor);
2043 value.insert(insert_at, c);
2044 });
2045 form.field_cursor = form.field_cursor.saturating_add(1);
2046}
2047
2048fn delete_account_form_char(form: &mut AccountFormState, backspace: bool) {
2049 let cursor = form.field_cursor;
2050 with_account_form_field_mut(form, |value| {
2051 if backspace {
2052 if cursor == 0 {
2053 return;
2054 }
2055 let start = char_to_byte_index(value, cursor - 1);
2056 let end = char_to_byte_index(value, cursor);
2057 value.replace_range(start..end, "");
2058 } else {
2059 let len = value.chars().count();
2060 if cursor >= len {
2061 return;
2062 }
2063 let start = char_to_byte_index(value, cursor);
2064 let end = char_to_byte_index(value, cursor + 1);
2065 value.replace_range(start..end, "");
2066 }
2067 });
2068 if backspace {
2069 form.field_cursor = form.field_cursor.saturating_sub(1);
2070 }
2071}
2072
2073fn char_to_byte_index(value: &str, char_index: usize) -> usize {
2074 value
2075 .char_indices()
2076 .nth(char_index)
2077 .map(|(index, _)| index)
2078 .unwrap_or(value.len())
2079}
2080
2081fn next_gmail_credential_source(
2082 current: mxr_protocol::GmailCredentialSourceData,
2083 forward: bool,
2084) -> mxr_protocol::GmailCredentialSourceData {
2085 match (current, forward) {
2086 (mxr_protocol::GmailCredentialSourceData::Bundled, true) => {
2087 mxr_protocol::GmailCredentialSourceData::Custom
2088 }
2089 (mxr_protocol::GmailCredentialSourceData::Custom, true) => {
2090 mxr_protocol::GmailCredentialSourceData::Bundled
2091 }
2092 (mxr_protocol::GmailCredentialSourceData::Bundled, false) => {
2093 mxr_protocol::GmailCredentialSourceData::Custom
2094 }
2095 (mxr_protocol::GmailCredentialSourceData::Custom, false) => {
2096 mxr_protocol::GmailCredentialSourceData::Bundled
2097 }
2098 }
2099}
2100
2101pub fn snooze_presets() -> [SnoozePreset; 4] {
2102 [
2103 SnoozePreset::TomorrowMorning,
2104 SnoozePreset::Tonight,
2105 SnoozePreset::Weekend,
2106 SnoozePreset::NextMonday,
2107 ]
2108}
2109
2110pub fn resolve_snooze_preset(
2111 preset: SnoozePreset,
2112 config: &mxr_config::SnoozeConfig,
2113) -> chrono::DateTime<chrono::Utc> {
2114 use chrono::{Datelike, Duration, Local, NaiveTime, Weekday};
2115
2116 let now = Local::now();
2117 match preset {
2118 SnoozePreset::TomorrowMorning => {
2119 let tomorrow = now.date_naive() + Duration::days(1);
2120 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2121 tomorrow
2122 .and_time(time)
2123 .and_local_timezone(now.timezone())
2124 .single()
2125 .unwrap()
2126 .with_timezone(&chrono::Utc)
2127 }
2128 SnoozePreset::Tonight => {
2129 let today = now.date_naive();
2130 let time = NaiveTime::from_hms_opt(config.evening_hour as u32, 0, 0).unwrap();
2131 let tonight = today
2132 .and_time(time)
2133 .and_local_timezone(now.timezone())
2134 .single()
2135 .unwrap()
2136 .with_timezone(&chrono::Utc);
2137 if tonight <= chrono::Utc::now() {
2138 tonight + Duration::days(1)
2139 } else {
2140 tonight
2141 }
2142 }
2143 SnoozePreset::Weekend => {
2144 let target_day = match config.weekend_day.as_str() {
2145 "sunday" => Weekday::Sun,
2146 _ => Weekday::Sat,
2147 };
2148 let days_until = (target_day.num_days_from_monday() as i64
2149 - now.weekday().num_days_from_monday() as i64
2150 + 7)
2151 % 7;
2152 let days = if days_until == 0 { 7 } else { days_until };
2153 let weekend = now.date_naive() + Duration::days(days);
2154 let time = NaiveTime::from_hms_opt(config.weekend_hour as u32, 0, 0).unwrap();
2155 weekend
2156 .and_time(time)
2157 .and_local_timezone(now.timezone())
2158 .single()
2159 .unwrap()
2160 .with_timezone(&chrono::Utc)
2161 }
2162 SnoozePreset::NextMonday => {
2163 let days_until_monday = (Weekday::Mon.num_days_from_monday() as i64
2164 - now.weekday().num_days_from_monday() as i64
2165 + 7)
2166 % 7;
2167 let days = if days_until_monday == 0 {
2168 7
2169 } else {
2170 days_until_monday
2171 };
2172 let monday = now.date_naive() + Duration::days(days);
2173 let time = NaiveTime::from_hms_opt(config.morning_hour as u32, 0, 0).unwrap();
2174 monday
2175 .and_time(time)
2176 .and_local_timezone(now.timezone())
2177 .single()
2178 .unwrap()
2179 .with_timezone(&chrono::Utc)
2180 }
2181 }
2182}