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