diff --git a/crates/openloops-desktop/src/app_model.rs b/crates/openloops-desktop/src/app_model.rs index dadeb8c..d70eb1e 100644 --- a/crates/openloops-desktop/src/app_model.rs +++ b/crates/openloops-desktop/src/app_model.rs @@ -12,7 +12,9 @@ use crate::settings::{ production_store, }; use openloops_graph::live::{ - ConnectionConfig, ConnectionError, ConnectionReport, MailProvider, clear_all_sessions, + AccountConfig, ConnectionConfig, ConnectionError, ConnectionReport, MailProvider, + clear_all_sessions, + google::GoogleConfig, registration, review::{LoadProgress, MailCache}, }; @@ -69,7 +71,7 @@ pub(crate) enum Outcome { #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum Service { - Mailbox, + Mailbox(MailProvider), Model, Review, } @@ -168,6 +170,30 @@ fn shared_registration_active_for(byo_field: &str, shipped: Option<&str>) -> boo byo_field.trim().is_empty() && shipped.is_some() } +fn effective_google_registration_for( + byo_client_id: &str, + byo_client_secret: &str, + shipped: Option<(&str, &str)>, +) -> Option<(String, Zeroizing)> { + let client_id = byo_client_id.trim(); + let client_secret = byo_client_secret.trim(); + if !client_id.is_empty() && !client_secret.is_empty() { + Some(( + client_id.to_owned(), + Zeroizing::new(client_secret.to_owned()), + )) + } else if client_id.is_empty() && client_secret.is_empty() { + shipped.map(|(client_id, client_secret)| { + ( + client_id.to_owned(), + Zeroizing::new(client_secret.to_owned()), + ) + }) + } else { + None + } +} + impl AppModel { #[must_use] pub fn new() -> Self { @@ -202,7 +228,7 @@ impl AppModel { model_status: Status::default(), decision_status: Status::default(), pending: None, - pending_service: Service::Mailbox, + pending_service: Service::Mailbox(MailProvider::Microsoft), progress: "", started: Instant::now(), store: None, @@ -299,7 +325,7 @@ impl AppModel { self.settings_status = Status { lines: vec![ if existed { - "Saved settings restored. Microsoft sign-in is still required." + "Saved settings restored. Sign in again to load mail." } else { "Settings will save automatically on this Windows account." } @@ -423,7 +449,7 @@ impl AppModel { succeeded: false, }; match self.pending_service { - Service::Mailbox => self.microsoft = status, + Service::Mailbox(provider) => *self.connection_status_mut(provider) = status, Service::Model => self.model_status = status, Service::Review => { self.load_progress = None; @@ -503,7 +529,7 @@ impl AppModel { }; } Outcome::Connection(provider, result) => { - *self.connection_status_mut(provider) = microsoft_status(result); + *self.connection_status_mut(provider) = connection_report_status(provider, result); } Outcome::Mail(Ok(sources)) => { self.load_progress = None; @@ -928,11 +954,42 @@ impl AppModel { /// the model owning any UI-nav state. #[must_use] pub fn ready_for_review(&self) -> bool { - self.effective_microsoft_client_id().is_some() + self.registration_present(self.active_mail_provider()) && !self.active_key().is_empty() && !self.selected_model().is_empty() } + fn registration_present(&self, provider: MailProvider) -> bool { + match provider { + MailProvider::Microsoft => self.effective_microsoft_client_id().is_some(), + MailProvider::Google => self.effective_google_registration().is_some(), + } + } + + #[must_use] + pub fn active_mail_provider(&self) -> MailProvider { + self.mail_providers + .iter() + .next() + .copied() + .unwrap_or(MailProvider::Microsoft) + } + + pub fn set_active_mail_provider(&mut self, provider: MailProvider) { + if self.mail_providers == BTreeSet::from([provider]) { + return; + } + self.mail_providers = BTreeSet::from([provider]); + clear_all_sessions(); + self.clear_mail_cache(); + self.microsoft = Status::default(); + self.google = Status::default(); + self.review = ReviewState::default(); + self.review_status = Status::default(); + self.pending_save = true; + let _ = self.persist_changes(); + } + #[must_use] pub fn effective_microsoft_client_id(&self) -> Option { registration::effective_client_id( @@ -949,6 +1006,39 @@ impl AppModel { ) } + #[must_use] + pub fn effective_google_registration(&self) -> Option<(String, Zeroizing)> { + effective_google_registration_for( + &self.google_client_id, + &self.google_client_secret, + registration::google().map(|value| (value.client_id, value.client_secret)), + ) + } + + /// Builds the selected provider's validated runtime configuration. + /// + /// # Errors + /// Returns [`ConnectionError::InvalidConfiguration`] when required + /// registration values are missing or malformed. + pub fn account_config(&self, provider: MailProvider) -> Result { + match provider { + MailProvider::Microsoft => { + let client_id = self + .effective_microsoft_client_id() + .ok_or(ConnectionError::InvalidConfiguration)?; + ConnectionConfig::new(&client_id, Some(&self.shared)) + .and_then(|config| config.with_groups(Some(&self.groups))) + .map(AccountConfig::Microsoft) + } + MailProvider::Google => { + let (client_id, client_secret) = self + .effective_google_registration() + .ok_or(ConnectionError::InvalidConfiguration)?; + GoogleConfig::new(&client_id, &client_secret).map(AccountConfig::Google) + } + } + } + #[must_use] pub fn admin_consent_url(&self) -> Option { self.effective_microsoft_client_id() @@ -963,7 +1053,7 @@ impl AppModel { } } - fn connection_status_mut(&mut self, provider: MailProvider) -> &mut Status { + pub(crate) fn connection_status_mut(&mut self, provider: MailProvider) -> &mut Status { match provider { MailProvider::Microsoft => &mut self.microsoft, MailProvider::Google => &mut self.google, @@ -1018,6 +1108,9 @@ impl AppModel { /// record carrying real (non-empty) ids has anything left to learn from /// Graph. pub(crate) fn dispatch_reminder_sync(&mut self) { + if self.active_mail_provider() != MailProvider::Microsoft { + return; + } let Some(analysis) = &self.review.analysis else { return; }; @@ -1188,7 +1281,10 @@ impl Default for AppModel { } } -pub(crate) fn microsoft_status(result: Result) -> Status { +pub(crate) fn connection_report_status( + provider: MailProvider, + result: Result, +) -> Status { match result { Err(error) => Status { lines: vec![error.to_string()], @@ -1201,16 +1297,20 @@ pub(crate) fn microsoft_status(result: Result "Personal inbox: access not confirmed.".into() }]; let mut succeeded = report.own_inbox_accessible; - for (kind, results) in [ - ("Group inbox", report.group_inbox_results), - ("Shared mailbox", report.shared_inbox_results), - ] { - for (index, result) in results.into_iter().enumerate() { - match result { - Ok(()) => lines.push(format!("{kind} {}: access confirmed.", index + 1)), - Err(error) => { - lines.push(format!("{kind} {}: {error}", index + 1)); - succeeded = false; + if provider == MailProvider::Microsoft { + for (kind, results) in [ + ("Group inbox", report.group_inbox_results), + ("Shared mailbox", report.shared_inbox_results), + ] { + for (index, result) in results.into_iter().enumerate() { + match result { + Ok(()) => { + lines.push(format!("{kind} {}: access confirmed.", index + 1)); + } + Err(error) => { + lines.push(format!("{kind} {}: {error}", index + 1)); + succeeded = false; + } } } } @@ -1313,6 +1413,7 @@ mod tests { use openloops_inference::decision::DecisionCheckReport; use std::{ cell::{Cell, RefCell}, + collections::HashMap, rc::Rc, }; @@ -1607,7 +1708,7 @@ mod tests { let mut app = AppModel::new(); let (sender, receiver) = mpsc::channel(); app.pending = Some(receiver); - app.pending_service = Service::Mailbox; + app.pending_service = Service::Mailbox(MailProvider::Microsoft); drop(sender); app.poll(|| {}); assert!(app.pending.is_none()); @@ -1615,6 +1716,22 @@ mod tests { assert!(app.model_status.lines.is_empty()); } + #[test] + fn disconnected_google_mailbox_worker_reports_on_google_status() { + let mut app = AppModel::with_store(Ok(None)); + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + app.pending_service = Service::Mailbox(MailProvider::Google); + drop(sender); + + assert!(app.poll(|| {})); + assert!(app.microsoft.lines.is_empty()); + assert_eq!( + app.google.lines, + ["The operation stopped unexpectedly. Please try again."] + ); + } + // Regression guard for the perf fix: the native adapter's busy-tick // timer decides between a full `refresh` and the lightweight // `sync_busy` based on this return value, so it must be exact -- @@ -1679,6 +1796,90 @@ mod tests { ); } + #[test] + fn active_mail_provider_defaults_and_switch_persists_a_single_provider() { + let memory = MemoryStore::default(); + let mut app = AppModel::with_store(Ok(Some(Box::new(memory.clone())))); + assert_eq!(app.active_mail_provider(), MailProvider::Microsoft); + app.client_id = "00000000-0000-4000-8000-000000000000".into(); + app.groups = "group@example.invalid".into(); + app.shared = "shared@example.invalid".into(); + app.google_client_id = "123-synthetic.apps.googleusercontent.com".into(); + app.google_client_secret = Zeroizing::new("synthetic-client-parameter".into()); + app.review = crate::review_model::layout_fixture(); + app.review.action_status = "Synthetic action status".into(); + app.review_status = Status { + lines: vec!["Synthetic review status".into()], + succeeded: true, + }; + app.mail_cache = Arc::new(HashMap::from([( + ("synthetic-account".into(), "synthetic-message".into()), + MailItem::default(), + )])); + + app.set_active_mail_provider(MailProvider::Google); + + assert_eq!(app.active_mail_provider(), MailProvider::Google); + assert_eq!(app.mail_providers, BTreeSet::from([MailProvider::Google])); + assert!(app.mail_cache.is_empty()); + assert!(app.review.analysis.is_none()); + assert!(app.review.action_status.is_empty()); + assert!(app.review_status.lines.is_empty()); + assert_eq!(app.client_id, "00000000-0000-4000-8000-000000000000"); + assert_eq!(app.groups, "group@example.invalid"); + assert_eq!(app.shared, "shared@example.invalid"); + assert_eq!( + app.google_client_id, + "123-synthetic.apps.googleusercontent.com" + ); + assert_eq!(&*app.google_client_secret, "synthetic-client-parameter"); + assert_eq!( + memory.saved.borrow().as_ref().unwrap().mail_providers, + BTreeSet::from([MailProvider::Google]) + ); + } + + #[test] + fn effective_google_registration_precedence_is_pure() { + let shipped = Some(( + "123-shipped.apps.googleusercontent.com", + "synthetic-shipped-parameter", + )); + let byo = effective_google_registration_for( + " 456-byo.apps.googleusercontent.com ", + " synthetic-byo-parameter ", + shipped, + ) + .unwrap(); + assert_eq!(byo.0, "456-byo.apps.googleusercontent.com"); + assert_eq!(&*byo.1, "synthetic-byo-parameter"); + + let shared = effective_google_registration_for("", " ", shipped).unwrap(); + assert_eq!(shared.0, "123-shipped.apps.googleusercontent.com"); + assert_eq!(&*shared.1, "synthetic-shipped-parameter"); + assert!(effective_google_registration_for("", "", None).is_none()); + assert!( + effective_google_registration_for("456-byo.apps.googleusercontent.com", "", shipped) + .is_none() + ); + assert!(effective_google_registration_for("", "synthetic", shipped).is_none()); + } + + #[test] + fn google_account_config_requires_both_valid_synthetic_fields() { + let mut app = AppModel::with_store(Ok(None)); + app.google_client_id = "123-synthetic.apps.googleusercontent.com".into(); + assert!(matches!( + app.account_config(MailProvider::Google), + Err(ConnectionError::InvalidConfiguration) + )); + app.google_client_secret = Zeroizing::new("synthetic-client-parameter".into()); + assert!(matches!( + app.account_config(MailProvider::Google), + Ok(AccountConfig::Google(_)) + )); + } + #[test] fn shared_registration_is_active_only_for_an_empty_byo_field_when_shipped() { assert!(shared_registration_active_for("", Some("synthetic"))); @@ -1919,17 +2120,35 @@ mod tests { #[test] fn group_failure_is_visible_and_does_not_mark_microsoft_ready() { - let status = microsoft_status(Ok(ConnectionReport { - own_inbox_accessible: true, - shared_scope: SharedScope::NotReported, - shared_inbox_results: vec![], - group_inbox_results: vec![Ok(()), Err(ConnectionError::AccessDenied), Ok(())], - })); + let status = connection_report_status( + MailProvider::Microsoft, + Ok(ConnectionReport { + own_inbox_accessible: true, + shared_scope: SharedScope::NotReported, + shared_inbox_results: vec![], + group_inbox_results: vec![Ok(()), Err(ConnectionError::AccessDenied), Ok(())], + }), + ); assert!(!status.succeeded); assert_eq!(status.lines.len(), 4); assert!(status.lines[2].contains("HTTP 403")); } + #[test] + fn google_connection_report_has_only_the_personal_inbox_line() { + let status = connection_report_status( + MailProvider::Google, + Ok(ConnectionReport { + own_inbox_accessible: true, + shared_scope: SharedScope::Granted, + shared_inbox_results: vec![Err(ConnectionError::AccessDenied)], + group_inbox_results: vec![Err(ConnectionError::AccessDenied)], + }), + ); + assert!(status.succeeded); + assert_eq!(status.lines, ["Personal inbox: access confirmed."]); + } + #[test] fn trusted_message_links_accept_only_allowlisted_https_hosts_with_paths() { assert!(is_trusted_message_link( @@ -2146,6 +2365,17 @@ mod tests { assert!(app.pending.is_none()); } + #[test] + fn dispatch_reminder_sync_is_a_noop_for_google() { + let mut app = AppModel::with_store(Ok(None)); + app.review = crate::review_model::layout_fixture(); + app.mail_providers = BTreeSet::from([MailProvider::Google]); + + app.dispatch_reminder_sync(); + + assert!(app.pending.is_none()); + } + #[test] fn reminder_outcome_maps_each_reason_to_exact_status_text() { use openloops_graph::live::reminders::{ReminderFailure, ReminderOutcome}; diff --git a/crates/openloops-desktop/src/slint_review.rs b/crates/openloops-desktop/src/slint_review.rs index 20ee05e..4796363 100644 --- a/crates/openloops-desktop/src/slint_review.rs +++ b/crates/openloops-desktop/src/slint_review.rs @@ -23,8 +23,7 @@ use crate::{ }, }; use openloops_graph::live::{ - ConnectionConfig, ConnectionError, MailProvider, - review::{LoadProgress, load_recent_with, load_sources_with}, + ConnectionConfig, ConnectionError, MailProvider, review::LoadProgress, }; use openloops_inference::blocks::CanonicalBlock; use slint::{ComponentHandle, Timer}; @@ -75,6 +74,8 @@ fn reminder_title(decision: Decision, action: &str) -> String { } const REMINDER_VALIDATION_HINT: &str = "Enter a future local date/time and a title of 3–320 bytes. Ambiguous daylight-saving times need a different time."; +const GOOGLE_REMINDER_UNAVAILABLE: &str = + "Reminders for Google accounts arrive in a later release."; const TODO_URL: &str = MailProvider::Microsoft.tasks_url(); #[derive(Clone, Debug, PartialEq, Eq)] @@ -1401,7 +1402,9 @@ fn sync_review_inner( }); window.set_review_no_usable_items(no_usable); window.set_review_no_usable_text(no_usable_text.into()); + let mut selected_can_remind = false; if let Some(selected) = selected_view(&model.review, review_ui.selected, cards) { + selected_can_remind = selected.can_remind; window.set_review_has_selection(true); window.set_review_title(selected.title.into()); let meta = selected @@ -1432,7 +1435,9 @@ fn sync_review_inner( window.set_selected_terminal(selected.terminal); window.set_can_track(selected.can_track && !busy); window.set_can_watch(selected.can_watch && !busy); - window.set_can_remind(selected.can_remind && !busy); + window.set_can_remind( + selected.can_remind && !busy && model.active_mail_provider() == MailProvider::Microsoft, + ); let has_links = review_ui.selected.is_some_and(|selected_key| { let loop_link = !model .review @@ -1593,10 +1598,19 @@ fn sync_review_inner( }); } let decision_error = model.review.decisions.error.as_deref(); - window.set_review_action_status(decision_error.unwrap_or(&model.review.action_status).into()); - window.set_review_action_status_succeeded( - decision_error.is_none() && model.review.action_status_succeeded, - ); + if let Some(error) = decision_error { + window.set_review_action_status(error.into()); + window.set_review_action_status_succeeded(false); + } else if model.active_mail_provider() == MailProvider::Google + && selected_can_remind + && model.review.action_status.is_empty() + { + window.set_review_action_status(GOOGLE_REMINDER_UNAVAILABLE.into()); + window.set_review_action_status_succeeded(false); + } else { + window.set_review_action_status(model.review.action_status.clone().into()); + window.set_review_action_status_succeeded(model.review.action_status_succeeded); + } } fn retry_failed_button(model: &AppModel, busy: bool) -> (usize, bool) { @@ -1621,14 +1635,28 @@ pub(crate) fn scan_mode(review: &ReviewState) -> ScanMode { } } +fn sign_in_label(provider: MailProvider, mode: ScanMode) -> &'static str { + match (provider, mode) { + (MailProvider::Microsoft, ScanMode::Full) => { + "Complete Microsoft sign-in; then downloading recent messages" + } + (MailProvider::Google, ScanMode::Full) => { + "Complete Google sign-in; then downloading recent messages" + } + (MailProvider::Microsoft, ScanMode::Incremental) => { + "Complete Microsoft sign-in; then checking for new mail" + } + (MailProvider::Google, ScanMode::Incremental) => { + "Complete Google sign-in; then checking for new mail" + } + } +} + fn start_mail_load(model: &Rc>, mode: ScanMode) -> Result<(), ConnectionError> { - let config = { + let (provider, config) = { let model = model.borrow(); - let client_id = model - .effective_microsoft_client_id() - .ok_or(ConnectionError::InvalidConfiguration)?; - ConnectionConfig::new(&client_id, Some(&model.shared)) - .and_then(|config| config.with_groups(Some(&model.groups)))? + let provider = model.active_mail_provider(); + (provider, model.account_config(provider)?) }; let mut model_ref = model.borrow_mut(); if mode == ScanMode::Full { @@ -1645,16 +1673,16 @@ fn start_mail_load(model: &Rc>, mode: ScanMode) -> Result<(), let progress = std::sync::Arc::new(LoadProgress::default()); let cache = std::sync::Arc::clone(&model_ref.mail_cache); model_ref.load_progress = Some(std::sync::Arc::clone(&progress)); - let signed_in = openloops_graph::live::has_session_for(MailProvider::Microsoft); + let signed_in = openloops_graph::live::has_session_for(provider); match mode { ScanMode::Full => model_ref.start( Service::Review, if signed_in { "Downloading recent messages" } else { - "Complete Microsoft sign-in; then downloading recent messages" + sign_in_label(provider, ScanMode::Full) }, - move || Outcome::Mail(load_recent_with(&config, &cache, &progress)), + move || Outcome::Mail(config.load_recent_with(&cache, &progress)), || {}, ), ScanMode::Incremental => model_ref.start( @@ -1662,10 +1690,10 @@ fn start_mail_load(model: &Rc>, mode: ScanMode) -> Result<(), if signed_in { "Checking for new mail" } else { - "Complete Microsoft sign-in; then checking for new mail" + sign_in_label(provider, ScanMode::Incremental) }, move || Outcome::CheckMail { - sources: load_recent_with(&config, &cache, &progress), + sources: config.load_recent_with(&cache, &progress), }, || {}, ), @@ -1986,15 +2014,10 @@ pub(crate) fn register_callbacks( .borrow_mut() .start_retry_scan(conversations, attempted, || {}); } else { - let config = { + let (provider, config) = { let model = model.borrow(); - model - .effective_microsoft_client_id() - .ok_or(ConnectionError::InvalidConfiguration) - .and_then(|client_id| { - ConnectionConfig::new(&client_id, Some(&model.shared)) - .and_then(|config| config.with_groups(Some(&model.groups))) - }) + let provider = model.active_mail_provider(); + (provider, model.account_config(provider)) }; match config { Ok(config) => { @@ -2004,14 +2027,13 @@ pub(crate) fn register_callbacks( model_ref.load_progress = Some(std::sync::Arc::clone(&progress)); model_ref.start( Service::Review, - if openloops_graph::live::has_session_for(MailProvider::Microsoft) { + if openloops_graph::live::has_session_for(provider) { "Downloading recent messages" } else { - "Complete Microsoft sign-in; then downloading recent messages" + sign_in_label(provider, ScanMode::Full) }, move || Outcome::RetryMail { - sources: load_sources_with( - &config, + sources: config.load_sources_with( &cache, &progress, Some(&sources), @@ -2472,6 +2494,13 @@ pub(crate) fn register_callbacks( let timer = Rc::clone(&timer); window.on_create_reminder(move || { let mut model_ref = model.borrow_mut(); + if model_ref.active_mail_provider() == MailProvider::Google { + model_ref.review.action_status = GOOGLE_REMINDER_UNAVAILABLE.into(); + model_ref.review.action_status_succeeded = false; + drop(model_ref); + refresh(&model, &weak); + return; + } let Some(draft) = model_ref.review.draft.take() else { return; }; @@ -2536,6 +2565,7 @@ pub(crate) fn register_callbacks( mod tests { use super::*; use crate::{deadline_view::DeadlineView, loop_state::Record, review_model::scan_strip}; + use slint::Model; fn model() -> AppModel { AppModel::with_store(Ok(None)) @@ -2935,6 +2965,71 @@ mod tests { fn a_single_native_window_covers_busy_guards_projection_cache_and_sync_busy() { let window = AppWindow::new().expect("create AppWindow for test"); let mut model = model(); + model.client_id = "00000000-0000-4000-8000-000000000000".into(); + model.microsoft.lines = vec!["Personal inbox: access confirmed.".into()]; + crate::slint_ui::sync(&model, &window); + assert_eq!(window.get_mail_provider_index(), 0); + assert!(window.get_can_check_mailbox()); + assert_eq!(window.get_microsoft_lines().row_count(), 1); + assert_eq!(window.get_google_lines().row_count(), 0); + + model.mail_providers = std::collections::BTreeSet::from([MailProvider::Google]); + model.google_client_id = "123-synthetic.apps.googleusercontent.com".into(); + model.google.lines = vec!["Personal inbox: access confirmed.".into()]; + crate::slint_ui::sync(&model, &window); + assert_eq!(window.get_mail_provider_index(), 1); + assert!(!window.get_can_check_mailbox()); + assert!(window.get_own_inbox_accessible()); + assert_eq!(window.get_microsoft_lines().row_count(), 0); + assert_eq!(window.get_google_lines().row_count(), 1); + + model.google_client_secret = zeroize::Zeroizing::new("synthetic-client-parameter".into()); + crate::slint_ui::sync(&model, &window); + assert!(window.get_can_check_mailbox()); + + let mailbox_callback_model = Rc::new(RefCell::new(AppModel::with_store(Ok(None)))); + mailbox_callback_model.borrow_mut().client_id = "malformed-guid".into(); + let mailbox_timer = Rc::new(Timer::default()); + crate::slint_ui::register_check_mailbox_callback( + &window, + &mailbox_callback_model, + &mailbox_timer, + ); + crate::slint_ui::register_mail_provider_callback(&window, &mailbox_callback_model); + crate::slint_ui::sync(&mailbox_callback_model.borrow(), &window); + assert!(window.get_can_check_mailbox()); + window.invoke_check_mailbox(); + assert_eq!( + mailbox_callback_model.borrow().microsoft.lines, + [ConnectionError::InvalidConfiguration.to_string()] + ); + + { + let mut callback_model = mailbox_callback_model.borrow_mut(); + callback_model.google_client_id = "123-synthetic.apps.googleusercontent.com".into(); + callback_model.google_client_secret = + zeroize::Zeroizing::new("synthetic-client-parameter".into()); + callback_model.review = crate::review_model::layout_fixture(); + callback_model.review.action_status = "Synthetic action status".into(); + callback_model.review_status = Status { + lines: vec!["Synthetic review status".into()], + succeeded: true, + }; + } + window.invoke_mail_provider_selected(1); + let callback_model = mailbox_callback_model.borrow(); + assert_eq!(callback_model.active_mail_provider(), MailProvider::Google); + assert!(callback_model.review.analysis.is_none()); + assert!(callback_model.review.action_status.is_empty()); + assert!(callback_model.review_status.lines.is_empty()); + assert_eq!(callback_model.client_id, "malformed-guid"); + assert_eq!( + callback_model.google_client_id, + "123-synthetic.apps.googleusercontent.com" + ); + drop(callback_model); + + model.mail_providers = std::collections::BTreeSet::from([MailProvider::Microsoft]); model.provider = crate::settings::Provider::OpenRouter; model.openrouter_key = zeroize::Zeroizing::new("synthetic-key".into()); crate::slint_ui::sync(&model, &window); @@ -2991,6 +3086,37 @@ mod tests { assert!(window.get_can_track()); assert!(!window.get_can_watch()); + model.review.action_status.clear(); + model.mail_providers = std::collections::BTreeSet::from([MailProvider::Google]); + sync_review( + &model, + &window, + &cards, + false, + false, + ScanStripModel::default(), + ); + assert!(!window.get_can_remind()); + assert_eq!( + window.get_review_action_status().as_str(), + GOOGLE_REMINDER_UNAVAILABLE + ); + model.review.decisions.error = Some("Synthetic decision error".into()); + sync_review( + &model, + &window, + &cards, + false, + false, + ScanStripModel::default(), + ); + assert_eq!( + window.get_review_action_status().as_str(), + "Synthetic decision error" + ); + model.review.decisions.error = None; + model.mail_providers = std::collections::BTreeSet::from([MailProvider::Microsoft]); + sync_review( &model, &window, @@ -3146,6 +3272,14 @@ mod tests { } let timer = Rc::new(Timer::default()); register_callbacks(&window, &callback_model, &timer); + callback_model.borrow_mut().mail_providers = + std::collections::BTreeSet::from([MailProvider::Google]); + window.invoke_create_reminder(); + assert_eq!( + callback_model.borrow().review.action_status, + GOOGLE_REMINDER_UNAVAILABLE + ); + assert!(callback_model.borrow().review.pending_reminder.is_none()); window.invoke_clear_results(); let model = callback_model.borrow(); assert!(cancel.cancel.load(Ordering::Relaxed)); diff --git a/crates/openloops-desktop/src/slint_ui.rs b/crates/openloops-desktop/src/slint_ui.rs index e67aded..1d2f762 100644 --- a/crates/openloops-desktop/src/slint_ui.rs +++ b/crates/openloops-desktop/src/slint_ui.rs @@ -11,9 +11,7 @@ use crate::{ }, training_export, }; -use openloops_graph::live::{ - ConnectionConfig, MailProvider, check_connection, clear_session_for, registration, -}; +use openloops_graph::live::{MailProvider, clear_session_for, registration}; use openloops_inference::{ decision::{OpenRouterDecisions, registry::Registry}, ollama::{OllamaCloud, available_models}, @@ -25,6 +23,7 @@ use zeroize::Zeroizing; slint::include_modules!(); const ENTRA_URL: &str = "https://entra.microsoft.com/"; +const GOOGLE_CONSOLE_URL: &str = "https://console.cloud.google.com/apis/credentials"; const MICROSOFT_LOGIN_URL_PREFIX: &str = "https://login.microsoftonline.com/"; const OLLAMA_KEYS_URL: &str = "https://ollama.com/settings/keys"; const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/settings/keys"; @@ -264,6 +263,7 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { window.set_account_text(account.name.into()); window.set_shipped_registration_present(registration::microsoft().is_some()); window.set_shared_registration_active(model.shared_registration_active()); + window.set_shipped_google_registration_present(registration::google().is_some()); window.set_admin_consent_url(model.admin_consent_url().unwrap_or_default().into()); window.set_review_badge( i32::try_from(open_badge_count(&cards, model.review.show_call_summaries)) @@ -291,7 +291,7 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { window.set_shared_mailboxes(model.shared.clone().into()); window.set_own_inbox_accessible( model - .microsoft + .connection_status(model.active_mail_provider()) .lines .first() .is_some_and(|line| line == "Personal inbox: access confirmed."), @@ -305,7 +305,29 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { succeeded: line.ends_with("access confirmed."), }) .collect::>(); - window.set_microsoft_lines(ModelRc::new(VecModel::from(microsoft_lines))); + window.set_microsoft_lines(ModelRc::new(VecModel::from( + if model.active_mail_provider() == MailProvider::Microsoft { + microsoft_lines + } else { + Vec::new() + }, + ))); + let google_lines = model + .google + .lines + .iter() + .map(|line| StatusLine { + text: line.clone().into(), + succeeded: line.ends_with("access confirmed."), + }) + .collect::>(); + window.set_google_lines(ModelRc::new(VecModel::from( + if model.active_mail_provider() == MailProvider::Google { + google_lines + } else { + Vec::new() + }, + ))); // The busy/progress line rides in its own property, always rendered in // `Tokens.text-2` (owner nit, perf-fix round: it used to piggyback on // `microsoft-lines`/`model-status` with `succeeded: false`, which read as @@ -313,13 +335,34 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { // these same two properties every tick without touching anything else on // this screen. window.set_microsoft_busy_line( - if model.pending_service == Service::Mailbox { + if model.pending_service == Service::Mailbox(MailProvider::Microsoft) { + busy_text.as_deref().unwrap_or_default() + } else { + "" + } + .into(), + ); + window.set_google_busy_line( + if model.pending_service == Service::Mailbox(MailProvider::Google) { busy_text.as_deref().unwrap_or_default() } else { "" } .into(), ); + window.set_mail_provider_index(match model.active_mail_provider() { + MailProvider::Microsoft => 0, + MailProvider::Google => 1, + }); + window.set_google_client_id(model.google_client_id.clone().into()); + let desired_google_secret = if window.get_active_screen() == 1 { + model.google_client_secret.to_string() + } else { + String::new() + }; + if window.get_google_client_secret().as_str() != desired_google_secret { + window.set_google_client_secret(desired_google_secret.into()); + } window.set_provider_index(match model.provider { Provider::OllamaCloud => 0, Provider::OpenRouter => 1, @@ -392,7 +435,10 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { Provider::OllamaCloud => "Load cloud models".into(), Provider::OpenRouter => "Load ZDR models".into(), }); - window.set_can_check_microsoft(model.effective_microsoft_client_id().is_some()); + window.set_can_check_mailbox(match model.active_mail_provider() { + MailProvider::Microsoft => model.effective_microsoft_client_id().is_some(), + MailProvider::Google => model.effective_google_registration().is_some(), + }); window.set_can_load_models(match model.provider { Provider::OllamaCloud => !model.key.is_empty(), Provider::OpenRouter => true, @@ -445,7 +491,10 @@ pub(crate) fn sync_busy(model: &AppModel, window: &AppWindow) { } match model.pending_service { - Service::Mailbox => window.set_microsoft_busy_line(busy_text.into()), + Service::Mailbox(MailProvider::Microsoft) => { + window.set_microsoft_busy_line(busy_text.into()); + } + Service::Mailbox(MailProvider::Google) => window.set_google_busy_line(busy_text.into()), Service::Model => window.set_model_busy_line(busy_text.into()), Service::Review => {} } @@ -466,13 +515,70 @@ fn finish_edit(model: &mut AppModel) { let _ = model.persist_changes(); } -fn clear_microsoft_after_edit(model: &mut AppModel) { - model.microsoft = Status::default(); +fn clear_mailbox_after_edit(model: &mut AppModel, provider: MailProvider) { + *model.connection_status_mut(provider) = Status::default(); model.review = ReviewState::default(); model.review_status = Status::default(); finish_edit(model); } +pub(crate) fn register_check_mailbox_callback( + window: &AppWindow, + model: &Rc>, + timer: &Rc, +) { + let model = Rc::clone(model); + let weak = window.as_weak(); + let timer = Rc::clone(timer); + window.on_check_mailbox(move || { + let (provider, config) = { + let model = model.borrow(); + let provider = model.active_mail_provider(); + (provider, model.account_config(provider)) + }; + match config { + Ok(config) => { + let mut model_ref = model.borrow_mut(); + *model_ref.connection_status_mut(provider) = Status::default(); + model_ref.start( + Service::Mailbox(provider), + match provider { + MailProvider::Microsoft => "Complete Microsoft sign-in in your browser", + MailProvider::Google => "Complete Google sign-in in your browser", + }, + move || Outcome::Connection(provider, config.check_connection()), + || {}, + ); + start_timer(&timer); + } + Err(error) => { + *model.borrow_mut().connection_status_mut(provider) = Status { + lines: vec![error.to_string()], + succeeded: false, + }; + } + } + refresh(&model, &weak); + }); +} + +pub(crate) fn register_mail_provider_callback(window: &AppWindow, model: &Rc>) { + let model = Rc::clone(model); + let weak = window.as_weak(); + window.on_mail_provider_selected(move |index| { + let provider = if index == 1 { + MailProvider::Google + } else { + MailProvider::Microsoft + }; + model.borrow_mut().set_active_mail_provider(provider); + if let Some(window) = weak.upgrade() { + window.set_show_google_secret(false); + } + refresh(&model, &weak); + }); +} + pub(crate) fn refresh(model: &Rc>, weak: &slint::Weak) { if let Some(window) = weak.upgrade() { sync(&model.borrow(), &window); @@ -680,7 +786,7 @@ pub fn run() -> Result<(), slint::PlatformError> { let (sender, receiver) = std::sync::mpsc::channel::(); std::mem::forget(sender); initial_model.pending = Some(receiver); - initial_model.pending_service = Service::Mailbox; + initial_model.pending_service = Service::Mailbox(MailProvider::Microsoft); initial_model.progress = "Complete Microsoft sign-in; then downloading recent messages"; initial_model.started = std::time::Instant::now() .checked_sub(Duration::from_secs(82)) @@ -716,6 +822,7 @@ pub fn run() -> Result<(), slint::PlatformError> { }; window.set_active_screen(initial_screen); window.set_show_key(false); + window.set_show_google_secret(false); // Preview-fixture only: start the reading pane's "Full scanned // conversation" disclosure expanded so a `--preview-review` screenshot // shows the message rows, the sent tint, and the nested quoted-history @@ -783,10 +890,12 @@ pub fn run() -> Result<(), slint::PlatformError> { }; if let Some(window) = weak.upgrade() { window.set_show_key(false); + window.set_show_google_secret(false); // X6: never let a stale key sit in the Slint text property // across a reload; `sync` below repopulates it only if the // Sources screen is (still) active. window.set_api_key("".into()); + window.set_google_client_secret("".into()); if succeeded { window.set_active_screen(i32::from(!active)); } @@ -801,11 +910,13 @@ pub fn run() -> Result<(), slint::PlatformError> { model.borrow_mut().forget_settings(); if let Some(window) = weak.upgrade() { window.set_show_key(false); + window.set_show_google_secret(false); // X6: the key is gone from the model; clear it from the // mirrored property immediately rather than waiting on // `sync`'s diff (which would happen to agree here anyway, // since the forgotten key is now empty). window.set_api_key("".into()); + window.set_google_client_secret("".into()); sync(&model.borrow(), &window); } }); @@ -825,6 +936,7 @@ pub fn run() -> Result<(), slint::PlatformError> { sync(&model.borrow(), &window); } else { window.set_api_key("".into()); + window.set_google_client_secret("".into()); } } } @@ -856,57 +968,55 @@ pub fn run() -> Result<(), slint::PlatformError> { } clear_session_for(MailProvider::Microsoft); model_ref.clear_mail_cache(); - clear_microsoft_after_edit(&mut model_ref); + clear_mailbox_after_edit(&mut model_ref, MailProvider::Microsoft); drop(model_ref); refresh(&model, &weak); }); } text_edit!(on_groups_edited, groups, model_ref, { - clear_microsoft_after_edit(&mut model_ref); + clear_mailbox_after_edit(&mut model_ref, MailProvider::Microsoft); }); text_edit!(on_shared_edited, shared, model_ref, { - clear_microsoft_after_edit(&mut model_ref); + clear_mailbox_after_edit(&mut model_ref, MailProvider::Microsoft); }); + register_check_mailbox_callback(&window, &model, &timer); + register_mail_provider_callback(&window, &model); { let model = Rc::clone(&model); let weak = window.as_weak(); - let timer = Rc::clone(&timer); - window.on_check_microsoft(move || { - let config = { - let model = model.borrow(); - model - .effective_microsoft_client_id() - .ok_or(openloops_graph::live::ConnectionError::InvalidConfiguration) - .and_then(|client_id| { - ConnectionConfig::new(&client_id, Some(&model.shared)) - .and_then(|config| config.with_groups(Some(&model.groups))) - }) - }; - match config { - Ok(config) => { - let mut model_ref = model.borrow_mut(); - model_ref.microsoft = Status::default(); - model_ref.start( - Service::Mailbox, - "Complete Microsoft sign-in in your browser", - move || { - Outcome::Connection(MailProvider::Microsoft, check_connection(&config)) - }, - || {}, - ); - start_timer(&timer); - } - Err(error) => { - model.borrow_mut().microsoft = Status { - lines: vec![error.to_string()], - succeeded: false, - }; - } + window.on_google_client_id_edited(move |value| { + let value: String = value.chars().take(256).collect(); + let mut model_ref = model.borrow_mut(); + if !replace_if_changed(&mut model_ref.google_client_id, value) { + return; } + clear_session_for(MailProvider::Google); + model_ref.clear_mail_cache(); + clear_mailbox_after_edit(&mut model_ref, MailProvider::Google); + drop(model_ref); refresh(&model, &weak); }); } + { + let model = Rc::clone(&model); + let weak = window.as_weak(); + window.on_google_client_secret_edited(move |value| { + let value = Zeroizing::new(value.chars().take(4096).collect::()); + let mut model_ref = model.borrow_mut(); + if !replace_if_changed(&mut model_ref.google_client_secret, value) { + return; + } + clear_session_for(MailProvider::Google); + model_ref.clear_mail_cache(); + clear_mailbox_after_edit(&mut model_ref, MailProvider::Google); + drop(model_ref); + refresh(&model, &weak); + }); + } + window.on_open_google_console(|| { + let _ = opener::open(GOOGLE_CONSOLE_URL); + }); { let model = Rc::clone(&model); let weak = window.as_weak(); diff --git a/crates/openloops-desktop/ui/app.slint b/crates/openloops-desktop/ui/app.slint index 9d8298d..9fd8e5d 100644 --- a/crates/openloops-desktop/ui/app.slint +++ b/crates/openloops-desktop/ui/app.slint @@ -45,6 +45,13 @@ export component AppWindow inherits Window { in property own-inbox-accessible; in property <[StatusLine]> microsoft-lines; in property microsoft-busy-line; + in-out property mail-provider-index; + in property shipped-google-registration-present; + in-out property google-client-id; + in-out property google-client-secret; + in-out property show-google-secret; + in property <[StatusLine]> google-lines; + in property google-busy-line; in-out property provider-index; in-out property api-key; in-out property show-key; @@ -66,7 +73,7 @@ export component AppWindow inherits Window { in property provider-disclosure; in property key-placeholder; in property load-models-label; - in property can-check-microsoft; + in property can-check-mailbox; in property can-load-models; in property can-test-model; in property can-check-decision-model; @@ -131,7 +138,11 @@ export component AppWindow inherits Window { callback client-id-edited(string); callback groups-edited(string); callback shared-edited(string); - callback check-microsoft; + callback mail-provider-selected(int); + callback google-client-id-edited(string); + callback google-client-secret-edited(string); + callback open-google-console; + callback check-mailbox; callback provider-selected(int); callback key-edited(string); callback open-entra; @@ -187,6 +198,9 @@ export component AppWindow inherits Window { shipped-registration-present: root.shipped-registration-present; shared-registration-active: root.shared-registration-active; admin-consent-url: root.admin-consent-url; client-id <=> root.client-id; groups <=> root.groups; shared-mailboxes <=> root.shared-mailboxes; own-inbox-accessible: root.own-inbox-accessible; microsoft-lines: root.microsoft-lines; microsoft-busy-line: root.microsoft-busy-line; + mail-provider-index <=> root.mail-provider-index; shipped-google-registration-present: root.shipped-google-registration-present; + google-client-id <=> root.google-client-id; google-client-secret <=> root.google-client-secret; show-google-secret <=> root.show-google-secret; + google-lines: root.google-lines; google-busy-line: root.google-busy-line; provider-index <=> root.provider-index; api-key <=> root.api-key; show-key <=> root.show-key; models: root.models; model-index <=> root.model-index; model-value <=> root.model-value; ollama-plan-index <=> root.ollama-plan-index; ollama-plan-options: root.ollama-plan-options; openrouter-parallel <=> root.openrouter-parallel; parallel-field-focused <=> root.parallel-field-focused; model-status: root.model-status; model-succeeded: root.model-succeeded; model-busy-line: root.model-busy-line; @@ -195,10 +209,13 @@ export component AppWindow inherits Window { training-export-folder <=> root.training-export-folder; can-export-training: root.can-export-training; training-export-status: root.training-export-status; training-export-succeeded: root.training-export-succeeded; provider-disclosure: root.provider-disclosure; key-placeholder: root.key-placeholder; load-models-label: root.load-models-label; - can-check-microsoft: root.can-check-microsoft; can-load-models: root.can-load-models; can-test-model: root.can-test-model; can-check-decision-model: root.can-check-decision-model; + can-check-mailbox: root.can-check-mailbox; can-load-models: root.can-load-models; can-test-model: root.can-test-model; can-check-decision-model: root.can-check-decision-model; save-settings => { root.save-settings(); } reload-settings => { root.reload-settings(); } forget-settings => { root.forget-settings(); } client-id-edited(value) => { root.client-id-edited(value); } groups-edited(value) => { root.groups-edited(value); } shared-edited(value) => { root.shared-edited(value); } - check-microsoft => { root.check-microsoft(); } provider-selected(index) => { root.provider-selected(index); } key-edited(value) => { root.key-edited(value); } + mail-provider-selected(index) => { root.mail-provider-selected(index); } + google-client-id-edited(value) => { root.google-client-id-edited(value); } google-client-secret-edited(value) => { root.google-client-secret-edited(value); } + open-google-console => { root.open-google-console(); } check-mailbox => { root.check-mailbox(); } + provider-selected(index) => { root.provider-selected(index); } key-edited(value) => { root.key-edited(value); } open-entra => { root.open-entra(); } open-entra-portal => { root.open-entra-portal(); } open-key-page => { root.open-key-page(); } load-models => { root.load-models(); } model-selected(value) => { root.model-selected(value); } plan-selected(index) => { root.plan-selected(index); } parallel-committed(value) => { root.parallel-committed(value); } test-model => { root.test-model(); } diff --git a/crates/openloops-desktop/ui/sources.slint b/crates/openloops-desktop/ui/sources.slint index 7320927..c1f6b06 100644 --- a/crates/openloops-desktop/ui/sources.slint +++ b/crates/openloops-desktop/ui/sources.slint @@ -59,6 +59,13 @@ export component SourcesScreen { // so an in-progress job never reads as a warning (owner nit, perf-fix // round: "should be text-2"). in property microsoft-busy-line; + in-out property mail-provider-index; + in property shipped-google-registration-present; + in-out property google-client-id; + in-out property google-client-secret; + in-out property show-google-secret; + in property <[StatusLine]> google-lines; + in property google-busy-line; in-out property provider-index; in-out property api-key; in-out property show-key; @@ -86,7 +93,7 @@ export component SourcesScreen { in property provider-disclosure; in property key-placeholder; in property load-models-label; - in property can-check-microsoft; + in property can-check-mailbox; in property can-load-models; in property can-test-model; in property can-check-decision-model; @@ -97,7 +104,11 @@ export component SourcesScreen { callback client-id-edited(string); callback groups-edited(string); callback shared-edited(string); - callback check-microsoft; + callback mail-provider-selected(int); + callback google-client-id-edited(string); + callback google-client-secret-edited(string); + callback open-google-console; + callback check-mailbox; callback provider-selected(int); callback key-edited(string); callback open-entra; @@ -137,14 +148,17 @@ export component SourcesScreen { VerticalLayout { width: 100%; padding-top: Tokens.pane-gutter-y; padding-bottom: Tokens.space-3; padding-left: Tokens.pane-gutter-x; padding-right: Tokens.pane-gutter-x; spacing: Tokens.space-2; VerticalLayout { spacing: Tokens.space-1; Text { text: "Sources and model"; color: Tokens.text-1; font-size: Tokens.font-22; font-weight: 600; } - HelperText { text: "Your own Inbox and Sent Items are included automatically. Settings save in Windows Credential Manager on this account."; width: 100%; } + HelperText { text: "Your own Inbox and Sent folders are included automatically. Settings save in Windows Credential Manager on this account."; width: 100%; } } HorizontalLayout { width: 100%; spacing: Tokens.space-5; Card { horizontal-stretch: 1; min-width: Tokens.card-min-width; VerticalLayout { width: 100%; alignment: start; padding-top: Tokens.card-pad-y; padding-bottom: Tokens.card-pad-y; padding-left: Tokens.card-pad-x; padding-right: Tokens.card-pad-x; spacing: Tokens.space-3; - StepHeading { number: "1"; title: "Connect Microsoft"; + StepHeading { number: "1"; title: "Connect your mailbox"; if root.own-inbox-accessible: Pill { text: "Personal inbox confirmed"; kind: "success"; } } + Text { text: "Mail provider"; color: Tokens.text-1; font-size: Tokens.font-14; font-weight: 600; width: 100%; horizontal-alignment: left; horizontal-stretch: 1; } + Segmented { options: ["Microsoft 365", "Google"]; selected-index <=> root.mail-provider-index; accessible-name: "Mail provider"; enabled: root.controls-enabled; selected(index) => { root.mail-provider-selected(index); } } + if root.mail-provider-index == 0: VerticalLayout { width: 100%; spacing: Tokens.space-3; if root.shipped-registration-present: VerticalLayout { width: 100%; spacing: Tokens.space-3; HelperText { text: "This build signs in with the OpenLoops registration. Your organization's administrator may need to approve it once."; width: 100%; } if root.client-id == "": VerticalLayout { width: 100%; spacing: Tokens.space-3; @@ -171,13 +185,43 @@ export component SourcesScreen { } } VerticalLayout { width: 100%; spacing: Tokens.space-1; - PrimaryButton { text: "Sign in & check inboxes"; enabled: root.controls-enabled && root.can-check-microsoft; clicked => { root.check-microsoft(); } } + PrimaryButton { text: "Sign in & check inboxes"; enabled: root.controls-enabled && root.can-check-mailbox; clicked => { root.check-mailbox(); } } HelperText { text: "Opens Microsoft sign-in in your browser. Checks access only; stores no mail."; width: 100%; } } VerticalLayout { width: 100%; spacing: Tokens.space-1; for line in root.microsoft-lines: WrappedText { text: line.text; text-color: line.succeeded ? Tokens.success : Tokens.warning; horizontal-stretch: 1; } if !root.microsoft-busy-line.is-empty: WrappedText { text: root.microsoft-busy-line; text-color: Tokens.text-2; horizontal-stretch: 1; } } + } + if root.mail-provider-index == 1: VerticalLayout { width: 100%; spacing: Tokens.space-3; + if root.shipped-google-registration-present: VerticalLayout { width: 100%; spacing: Tokens.space-3; + HelperText { text: "This build signs in with the OpenLoops Google client. Google shows an \"unverified app\" notice once; continue past it. Your address must be on the OpenLoops test-user list while the app is in Google's testing mode."; width: 100%; } + Disclosure { title: "Use my own Google Cloud client (optional)"; expanded: root.google-client-id != "" || root.google-client-secret != ""; enabled: root.controls-enabled; + VerticalLayout { spacing: Tokens.space-1; + Field { label: "Google client ID"; placeholder: "…apps.googleusercontent.com"; value <=> root.google-client-id; monospace: true; enabled: root.controls-enabled; edited(value) => { root.google-client-id-edited(value); } } + Field { label: "Google client secret"; value <=> root.google-client-secret; password: true; reveal-password: root.show-google-secret; monospace: true; enabled: root.controls-enabled; edited(value) => { root.google-client-secret-edited(value); } } + Checkbox { text: "Show secret"; checked <=> root.show-google-secret; enabled: root.controls-enabled; } + LinkButton { text: "Open Google Cloud console"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-google-console(); } } + HelperText { text: "Only if you created your own Google Cloud project. Leave blank to use the OpenLoops client."; width: 100%; } + } + } + } + if !root.shipped-google-registration-present: VerticalLayout { width: 100%; spacing: Tokens.space-3; + Field { label: "Google client ID"; placeholder: "…apps.googleusercontent.com"; value <=> root.google-client-id; monospace: true; enabled: root.controls-enabled; edited(value) => { root.google-client-id-edited(value); } } + Field { label: "Google client secret"; value <=> root.google-client-secret; password: true; reveal-password: root.show-google-secret; monospace: true; enabled: root.controls-enabled; edited(value) => { root.google-client-secret-edited(value); } } + Checkbox { text: "Show secret"; checked <=> root.show-google-secret; enabled: root.controls-enabled; } + LinkButton { text: "Open Google Cloud console"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-google-console(); } } + HelperText { text: "Create a Desktop OAuth client in Google Cloud and enable the Gmail API, then paste both values."; width: 100%; } + } + VerticalLayout { width: 100%; spacing: Tokens.space-1; + PrimaryButton { text: "Sign in & check inbox"; enabled: root.controls-enabled && root.can-check-mailbox; clicked => { root.check-mailbox(); } } + HelperText { text: "Opens Google sign-in in your browser. Checks access only; stores no mail."; width: 100%; } + } + VerticalLayout { width: 100%; spacing: Tokens.space-1; + for line in root.google-lines: WrappedText { text: line.text; text-color: line.succeeded ? Tokens.success : Tokens.warning; horizontal-stretch: 1; } + if !root.google-busy-line.is-empty: WrappedText { text: root.google-busy-line; text-color: Tokens.text-2; horizontal-stretch: 1; } + } + } } } Card { horizontal-stretch: 1; min-width: Tokens.card-min-width; @@ -249,8 +293,8 @@ export component SourcesScreen { Text { text: "Scan scope and data sent to the model provider"; color: Tokens.text-1; font-size: Tokens.font-15; font-weight: 600; width: 100%; horizontal-alignment: left; horizontal-stretch: 1; } HorizontalLayout { width: 100%; spacing: Tokens.space-6; VerticalLayout { horizontal-stretch: 1; Text { text: "Window"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: "Last 30 days"; text-color: Tokens.text-1; weight: 600; } } - VerticalLayout { horizontal-stretch: 1; Text { text: "Inbox / Sent Items"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: "Up to 100 messages per folder"; text-color: Tokens.text-1; weight: 600; } } - VerticalLayout { horizontal-stretch: 1; Text { text: "Groups"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: "20 recent threads · 40 posts each"; text-color: Tokens.text-1; weight: 600; } } + VerticalLayout { horizontal-stretch: 1; Text { text: root.mail-provider-index == 1 ? "Inbox / Sent" : "Inbox / Sent Items"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: "Up to 100 messages per folder"; text-color: Tokens.text-1; weight: 600; } } + VerticalLayout { horizontal-stretch: 1; Text { text: "Groups"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: root.mail-provider-index == 1 ? "Microsoft only" : "20 recent threads · 40 posts each"; text-color: Tokens.text-1; weight: 600; } } VerticalLayout { horizontal-stretch: 1; Text { text: "Sources"; color: Tokens.text-2; font-size: Tokens.font-13; } WrappedText { text: "At most 10 configured"; text-color: Tokens.text-1; weight: 600; } } } // B2: both sentences are the old egui build's wording verbatim diff --git a/crates/openloops-graph/src/encoding.rs b/crates/openloops-graph/src/encoding.rs index ba3701f..da0673b 100644 --- a/crates/openloops-graph/src/encoding.rs +++ b/crates/openloops-graph/src/encoding.rs @@ -47,6 +47,38 @@ pub fn base64url_encode(input: &[u8]) -> String { out } +/// Decodes unpadded RFC 4648 section 5 base64url data. +#[must_use] +pub fn base64url_decode(input: &str) -> Option> { + if input.len() % 4 == 1 { + return None; + } + let mut values = Vec::with_capacity(input.len()); + for byte in input.bytes() { + values.push(match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'-' => 62, + b'_' => 63, + _ => return None, + }); + } + let mut out = Vec::with_capacity(input.len() * 3 / 4); + for chunk in values.chunks(4) { + let first = *chunk.first()?; + let second = *chunk.get(1)?; + out.push((first << 2) | (second >> 4)); + if let Some(&third) = chunk.get(2) { + out.push((second << 4) | (third >> 2)); + if let Some(&fourth) = chunk.get(3) { + out.push((third << 6) | fourth); + } + } + } + Some(out) +} + fn push_group(out: &mut String, n: u32, chars: usize) { let indices = [ (n >> 18) & 0x3F, @@ -150,7 +182,10 @@ fn hex_value(byte: u8) -> Option { #[cfg(test)] mod tests { - use super::{base32url_encode, base64url_encode, percent_decode, percent_encode_query_value}; + use super::{ + base32url_encode, base64url_decode, base64url_encode, percent_decode, + percent_encode_query_value, + }; #[test] fn base64url_of_sha256_is_a_stable_deterministic_regression_anchor() { @@ -201,6 +236,14 @@ mod tests { assert_eq!(base64url_encode(b"foob"), "Zm9vYg"); assert_eq!(base64url_encode(b"fooba"), "Zm9vYmE"); assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy"); + for value in [b"".as_slice(), b"f", b"fo", b"foo", b"foob", b"foobar"] { + assert_eq!( + base64url_decode(&base64url_encode(value)).as_deref(), + Some(value) + ); + } + assert_eq!(base64url_decode("!"), None); + assert_eq!(base64url_decode("x"), None); } #[test] diff --git a/crates/openloops-graph/src/live.rs b/crates/openloops-graph/src/live.rs index 19b0c58..18c7756 100644 --- a/crates/openloops-graph/src/live.rs +++ b/crates/openloops-graph/src/live.rs @@ -131,7 +131,7 @@ impl std::fmt::Display for ConnectionError { Self::ConsentDenied => "Sign-in was declined or could not be completed.", Self::AdminConsentRequired => "Your organization requires an administrator to approve this app. Send the admin consent link to your IT administrator, or enter your organization's own Application ID.", Self::PublisherNotTrusted => "Your organization does not allow this app's publisher. Enter your organization's own Application ID.", - Self::ProviderUnavailable => "Google is not available in this build yet.", + Self::ProviderUnavailable => "The selected provider is unavailable.", Self::Transport => "The mail service could not be reached over a secure connection.", Self::Timeout(seconds) => { return write!(f, "The service did not answer within {seconds} seconds."); @@ -368,6 +368,15 @@ fn with_scopes( } fn run_with_session( + required: BTreeSet, + authorize_session: impl FnMut(BTreeSet) -> Result, + work: impl FnMut(&str, SharedScope) -> Result, +) -> Result { + run_with_session_for(MailProvider::Microsoft, required, authorize_session, work) +} + +pub(super) fn run_with_session_for( + provider: MailProvider, required: BTreeSet, mut authorize_session: impl FnMut(BTreeSet) -> Result, mut work: impl FnMut(&str, SharedScope) -> Result, @@ -375,7 +384,7 @@ fn run_with_session( let mut prior_scopes = BTreeSet::new(); let cached = { let mut sessions = session_store(); - let stored = &mut sessions[MailProvider::Microsoft.slot()]; + let stored = &mut sessions[provider.slot()]; if let Some(session) = stored.as_ref() { prior_scopes.clone_from(&session.scopes); } @@ -393,7 +402,7 @@ fn run_with_session( }; if let Some((token, shared)) = cached { match work(token.as_str(), shared) { - Err(ConnectionError::Unauthorized) => clear_session(), + Err(ConnectionError::Unauthorized) => clear_session_for(provider), result => return result, } } @@ -401,10 +410,10 @@ fn run_with_session( let session = authorize_session(prior_scopes)?; let token = session.access_token.clone(); let shared = session.shared; - session_store()[MailProvider::Microsoft.slot()] = Some(session); + session_store()[provider.slot()] = Some(session); let result = work(token.as_str(), shared); if matches!(result, Err(ConnectionError::Unauthorized)) { - clear_session(); + clear_session_for(provider); } result } @@ -427,7 +436,7 @@ fn required_scopes(config: &ConnectionConfig, reminders: bool) -> BTreeSet Result { +pub(super) fn graph_client() -> Result { Client::builder() .https_only(true) .no_proxy() @@ -700,7 +709,7 @@ fn bounded_body_with_timeout( #[cfg(test)] mod tests { - use super::test_support::scripted_server; + use super::test_support::{routed_server, scripted_server}; use super::*; const APP: &str = "11111111-1111-4111-8111-111111111111"; static SESSION_TEST: Mutex<()> = Mutex::new(()); @@ -786,6 +795,56 @@ mod tests { assert!(!has_session_for(MailProvider::Google)); } + #[test] + fn google_unauthorized_clears_only_google_session() { + let _serial = SESSION_TEST.lock().unwrap(); + clear_all_sessions(); + let required = BTreeSet::from(["mail".to_owned()]); + session_store()[MailProvider::Microsoft.slot()] = + Some(synthetic_session(required.clone(), Duration::from_mins(2))); + session_store()[MailProvider::Google.slot()] = + Some(synthetic_session(required.clone(), Duration::from_mins(2))); + let result: Result<(), ConnectionError> = run_with_session_for( + MailProvider::Google, + required, + |_| Err(ConnectionError::Unauthorized), + |_, _| Err(ConnectionError::Unauthorized), + ); + assert_eq!(result, Err(ConnectionError::Unauthorized)); + assert!(has_session_for(MailProvider::Microsoft)); + assert!(!has_session_for(MailProvider::Google)); + clear_all_sessions(); + } + + #[test] + fn google_listing_unauthorized_clears_only_google_session() { + let _serial = SESSION_TEST.lock().unwrap(); + clear_all_sessions(); + let required = BTreeSet::from(["mail".to_owned()]); + session_store()[MailProvider::Microsoft.slot()] = + Some(synthetic_session(required.clone(), Duration::from_mins(2))); + session_store()[MailProvider::Google.slot()] = + Some(synthetic_session(required.clone(), Duration::from_mins(2))); + let response = + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(); + let (origin, _, server) = routed_server(vec![( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response, + )]); + let http = Client::builder().no_proxy().build().unwrap(); + let result = run_with_session_for( + MailProvider::Google, + required, + |_| Err(ConnectionError::Unauthorized), + |token, _| google::gmail::list_rows(&http, token, &origin, "INBOX"), + ); + server.join().unwrap(); + assert_eq!(result, Err(ConnectionError::Unauthorized)); + assert!(has_session_for(MailProvider::Microsoft)); + assert!(!has_session_for(MailProvider::Google)); + clear_all_sessions(); + } + #[test] fn cached_unauthorized_clears_and_reauthorizes_exactly_once() { let _serial = SESSION_TEST.lock().unwrap(); @@ -819,6 +878,40 @@ mod tests { clear_session(); } + #[test] + fn google_cached_unauthorized_clears_and_reauthorizes_exactly_once() { + let _serial = SESSION_TEST.lock().unwrap(); + clear_all_sessions(); + let required = BTreeSet::from(["scope".to_owned()]); + session_store()[MailProvider::Google.slot()] = + Some(synthetic_session(required.clone(), Duration::from_mins(2))); + let mut authorizations = 0; + let mut work_calls = 0; + let result = run_with_session_for( + MailProvider::Google, + required.clone(), + |requested| { + authorizations += 1; + assert_eq!(requested, required); + let mut session = synthetic_session(requested, Duration::from_mins(2)); + session.access_token = Secret::new("replacement-token".to_owned()); + Ok(session) + }, + |token, _| { + work_calls += 1; + if token == "synthetic-token" { + Err(ConnectionError::Unauthorized) + } else { + Ok(()) + } + }, + ); + assert_eq!(result, Ok(())); + assert_eq!(authorizations, 1); + assert_eq!(work_calls, 2); + clear_all_sessions(); + } + #[test] fn insufficient_session_scope_authorizes_the_union() { let _serial = SESSION_TEST.lock().unwrap(); diff --git a/crates/openloops-graph/src/live/google/gmail.rs b/crates/openloops-graph/src/live/google/gmail.rs new file mode 100644 index 0000000..bbbabb6 --- /dev/null +++ b/crates/openloops-graph/src/live/google/gmail.rs @@ -0,0 +1,774 @@ +use std::collections::BTreeSet; +use std::sync::atomic::Ordering; + +use chrono::SecondsFormat; +use reqwest::blocking::Client; +use serde_json::Value; +use url::Url; + +use super::mime::{decode_rfc2047, header, parse_address_list, select_body}; +use super::{GoogleConfig, with_google_session}; +use crate::live::review::{ + LoadProgress, MailCache, MailItem, SourceReview, UserIdentity, add_hydrated, cutoff_timestamp, + fetch_from_origin_with_headers, +}; +use crate::live::{ConnectionError, MailProvider}; + +pub(super) const GMAIL_ORIGIN: &str = "https://gmail.googleapis.com/"; +pub(super) const USERINFO_ORIGIN: &str = "https://openidconnect.googleapis.com/"; +const LABELS: [(&str, &str); 2] = [("INBOX", "Gmail / Inbox"), ("SENT", "Gmail / Sent")]; + +fn bounded_text(value: &Value, field: &str, limit: usize) -> Result { + let text = value + .get(field) + .and_then(Value::as_str) + .ok_or(ConnectionError::ResourceUnavailable)?; + if text.chars().count() > limit { + return Err(ConnectionError::ResponseTooLarge); + } + Ok(text.to_owned()) +} + +fn optional_text( + value: &Value, + field: &str, + limit: usize, +) -> Result, ConnectionError> { + match value.get(field) { + None | Some(Value::Null) => Ok(None), + Some(_) => bounded_text(value, field, limit).map(|text| (!text.is_empty()).then_some(text)), + } +} + +pub(super) fn identity_from_origin( + http: &Client, + token: &str, + origin: &str, +) -> Result { + let url = Url::parse(origin) + .and_then(|url| url.join("v1/userinfo")) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + let bytes = fetch_from_origin_with_headers(http, token, &url, origin, &[])?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| ConnectionError::ResourceUnavailable)?; + let sub = bounded_text(&value, "sub", 512)?; + let email = bounded_text(&value, "email", 512)?; + if sub.is_empty() || email.is_empty() { + return Err(ConnectionError::ResourceUnavailable); + } + Ok(UserIdentity { + provider: MailProvider::Google, + account: format!("google:{sub}"), + addresses: vec![email.to_ascii_lowercase()], + display_name: optional_text(&value, "name", 4096)?, + given_name: optional_text(&value, "given_name", 4096)?, + }) +} + +/// Loads both recent Gmail sources into memory. +/// +/// # Errors +/// Returns fixed, content-free configuration, authorization, transport, or cancellation errors. +pub fn load_recent_with( + config: &GoogleConfig, + cache: &MailCache, + progress: &LoadProgress, +) -> Result, ConnectionError> { + load_sources_with(config, cache, progress, None) +} + +/// Loads selected recent Gmail sources into memory. +/// +/// # Errors +/// Returns fixed, content-free configuration, authorization, transport, or cancellation errors. +pub fn load_sources_with( + config: &GoogleConfig, + cache: &MailCache, + progress: &LoadProgress, + filter: Option<&BTreeSet>, +) -> Result, ConnectionError> { + if progress.cancel.load(Ordering::Acquire) { + return Err(ConnectionError::Cancelled); + } + with_google_session(config, false, |http, token| { + load_sources_from_origins( + http, + token, + cache, + progress, + filter, + USERINFO_ORIGIN, + GMAIL_ORIGIN, + ) + }) +} + +fn selected(filter: Option<&BTreeSet>, label: &str) -> bool { + filter.is_none_or(|filter| { + filter.contains(label) + || label + .rsplit_once(" / ") + .is_some_and(|(mailbox, _)| filter.contains(mailbox)) + }) +} + +fn load_sources_from_origins( + http: &Client, + token: &str, + cache: &MailCache, + progress: &LoadProgress, + filter: Option<&BTreeSet>, + userinfo_origin: &str, + gmail_origin: &str, +) -> Result, ConnectionError> { + progress.listed.store(0, Ordering::Relaxed); + progress.loaded.store(0, Ordering::Relaxed); + progress.sources_done.store(0, Ordering::Relaxed); + progress.sources_total.store( + LABELS + .iter() + .filter(|(_, label)| selected(filter, label)) + .count(), + Ordering::Relaxed, + ); + let identity = identity_from_origin(http, token, userinfo_origin)?; + let mut sources = Vec::new(); + for (remote_label, display_label) in LABELS { + if !selected(filter, display_label) { + continue; + } + if progress.cancel.load(Ordering::Acquire) { + return Err(ConnectionError::Cancelled); + } + sources.push(load_label( + http, + token, + gmail_origin, + remote_label, + display_label, + &identity, + cache, + progress, + )?); + progress.sources_done.fetch_add(1, Ordering::Relaxed); + } + Ok(sources) +} + +fn listing_url( + origin: &str, + label: &str, + page_token: Option<&str>, +) -> Result { + let mut url = Url::parse(origin) + .and_then(|url| url.join("gmail/v1/users/me/messages")) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + url.query_pairs_mut() + .append_pair("labelIds", label) + .append_pair("q", "newer_than:30d") + .append_pair("maxResults", "100"); + if let Some(token) = page_token { + url.query_pairs_mut().append_pair("pageToken", token); + } + Ok(url) +} + +pub(in crate::live) fn list_rows( + http: &Client, + token: &str, + origin: &str, + label: &str, +) -> Result<(Vec, bool), ConnectionError> { + let mut rows = Vec::new(); + let mut page_token = None; + for _ in 0..10 { + let url = listing_url(origin, label, page_token.as_deref())?; + let bytes = fetch_from_origin_with_headers(http, token, &url, origin, &[])?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| ConnectionError::ResourceUnavailable)?; + let messages = value + .get("messages") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let remaining = 100usize.saturating_sub(rows.len()); + rows.extend(messages.iter().take(remaining).cloned()); + let next = value + .get("nextPageToken") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if rows.len() == 100 { + return Ok((rows, next.is_some() || messages.len() > remaining)); + } + let Some(next) = next else { + return Ok((rows, false)); + }; + page_token = Some(next.to_owned()); + } + Ok((rows, true)) +} + +#[expect( + clippy::too_many_arguments, + reason = "mailbox loading keeps the request, identity, cache, and progress inputs explicit" +)] +fn load_label( + http: &Client, + token: &str, + origin: &str, + remote_label: &str, + display_label: &str, + identity: &UserIdentity, + cache: &MailCache, + progress: &LoadProgress, +) -> Result { + let mut source = SourceReview { + provider: MailProvider::Google, + label: display_label.to_owned(), + messages: vec![], + errors: vec![], + message_errors: vec![], + partial: false, + failed: false, + }; + let (rows, partial) = match list_rows(http, token, origin, remote_label) { + Ok(result) => result, + Err(ConnectionError::Unauthorized) => return Err(ConnectionError::Unauthorized), + Err(error) => { + source.errors.push(error); + source.failed = true; + return Ok(source); + } + }; + source.partial = partial; + add_hydrated( + &mut source, + &rows, + remote_label == "SENT", + false, + identity, + cache, + progress, + |row| hydrate(http, token, origin, &row), + )?; + let cutoff = cutoff_timestamp(); + source.messages.retain(|message| { + chrono::DateTime::parse_from_rfc3339(&message.received) + .is_ok_and(|received| received.timestamp() >= cutoff) + }); + Ok(source) +} + +fn message_url(origin: &str, id: &str) -> Result { + if id.is_empty() || id == "." || id == ".." { + return Err(ConnectionError::ResourceUnavailable); + } + let mut url = Url::parse(origin).map_err(|_| ConnectionError::InvalidConfiguration)?; + { + let mut path = url + .path_segments_mut() + .map_err(|()| ConnectionError::InvalidConfiguration)?; + path.pop_if_empty() + .extend(["gmail", "v1", "users", "me", "messages"]) + .push(id); + } + url.query_pairs_mut().append_pair("format", "full"); + Ok(url) +} + +fn hydrate( + http: &Client, + token: &str, + origin: &str, + row: &Value, +) -> Result { + let id = bounded_text(row, "id", 2048)?; + let url = message_url(origin, &id)?; + let bytes = + fetch_from_origin_with_headers(http, token, &url, origin, &[]).map_err(|error| { + if error == ConnectionError::ResponseTooLarge { + ConnectionError::MessageTooLarge + } else { + error + } + })?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| ConnectionError::ResourceUnavailable)?; + if value.get("id").and_then(Value::as_str) != Some(id.as_str()) { + return Err(ConnectionError::ResourceUnavailable); + } + item(&value) +} + +fn bounded_decoded_header( + payload: &Value, + name: &str, + limit: usize, +) -> Result { + let decoded = decode_rfc2047(header(payload, name).unwrap_or_default()); + if decoded.chars().count() > limit { + Err(ConnectionError::ResponseTooLarge) + } else { + Ok(decoded) + } +} + +fn checked_addresses( + payload: &Value, + name: &str, +) -> Result, String)>, ConnectionError> { + let addresses = parse_address_list(header(payload, name).unwrap_or_default()); + if addresses.iter().any(|(display_name, address)| { + display_name + .as_ref() + .is_some_and(|name| name.chars().count() > 4096) + || address.chars().count() > 512 + }) { + Err(ConnectionError::ResponseTooLarge) + } else { + Ok(addresses) + } +} + +fn item(value: &Value) -> Result { + let payload = value + .get("payload") + .ok_or(ConnectionError::ResourceUnavailable)?; + let id = bounded_text(value, "id", 2048)?; + let conversation = bounded_text(value, "threadId", 2048)?; + let milliseconds = value + .get("internalDate") + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()) + .ok_or(ConnectionError::ResourceUnavailable)?; + let received = chrono::DateTime::::from_timestamp_millis(milliseconds) + .ok_or(ConnectionError::ResourceUnavailable)? + .to_rfc3339_opts(SecondsFormat::Secs, true); + let (body, body_is_html) = select_body(payload)?.ok_or(ConnectionError::ResourceUnavailable)?; + let sender_rows = checked_addresses(payload, "From")?; + let (sender_name, sender_address) = sender_rows.into_iter().next().unwrap_or_default(); + let sender = sender_name.map_or_else( + || sender_address.clone(), + |name| format!("{name} <{sender_address}>"), + ); + let addresses = |name| -> Result, ConnectionError> { + Ok(checked_addresses(payload, name)? + .into_iter() + .map(|(_, address)| address) + .collect()) + }; + let labels = value + .get("labelIds") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + Ok(MailItem { + provider: MailProvider::Google, + subject: bounded_decoded_header(payload, "Subject", 8192)?, + body, + body_is_html, + sender, + received, + id: id.clone(), + conversation, + sender_address, + to: addresses("To")?, + cc: addresses("Cc")?, + sent: labels.iter().any(|label| label.as_str() == Some("SENT")), + team: false, + web_link: format!("https://mail.google.com/mail/u/0/#all/{id}"), + event: None, + ..MailItem::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::encoding::base64url_encode; + use crate::live::test_support::routed_server; + + fn response(status: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() + } + + fn identity_body() -> String { + serde_json::json!({"sub":"synthetic-sub","email":"user@example.invalid"}).to_string() + } + + fn message_body(id: &str, thread: &str, encoded_body: &str, date: &str) -> String { + serde_json::json!({ + "id":id, "threadId":thread, "internalDate":date, "labelIds":["INBOX"], + "payload":{"mimeType":"text/plain","headers":[ + {"name":"Subject","value":"Synthetic subject"}, + {"name":"From","value":"sender@example.invalid"} + ],"body":{"data":encoded_body}} + }) + .to_string() + } + + #[test] + fn identity_is_bounded_and_normalized() { + let body = serde_json::json!({"sub":"synthetic-sub","email":"USER@example.invalid","name":"Synthetic User","given_name":"Synthetic"}).to_string(); + let (origin, _, server) = routed_server(vec![("/v1/userinfo", response("200 OK", &body))]); + let http = Client::builder().no_proxy().build().unwrap(); + let identity = identity_from_origin(&http, "synthetic-token", &origin).unwrap(); + assert_eq!(identity.account, "google:synthetic-sub"); + assert_eq!(identity.addresses, ["user@example.invalid"]); + server.join().unwrap(); + } + + #[test] + fn identity_rejects_empty_or_missing_subject() { + for body in [ + serde_json::json!({"sub":"","email":"user@example.invalid"}).to_string(), + serde_json::json!({"email":"user@example.invalid"}).to_string(), + ] { + let (origin, _, server) = + routed_server(vec![("/v1/userinfo", response("200 OK", &body))]); + let http = Client::builder().no_proxy().build().unwrap(); + assert!(matches!( + identity_from_origin(&http, "synthetic-token", &origin), + Err(ConnectionError::ResourceUnavailable) + )); + server.join().unwrap(); + } + } + + #[test] + fn item_maps_gmail_shape_and_mime() { + let body = base64url_encode(b"

Synthetic body

"); + let value = serde_json::json!({ + "id":"synthetic-id", "threadId":"synthetic-thread", "internalDate":"1893456000000", "labelIds":["SENT"], + "payload":{"mimeType":"text/html","headers":[ + {"name":"Subject","value":"=?UTF-8?B?U3ludGhldGljIOKckw==?="}, + {"name":"From","value":"Synthetic Sender "}, + {"name":"To","value":"TO@example.invalid"}, {"name":"Cc","value":"CC@example.invalid"} + ],"body":{"data":body}} + }); + let message = item(&value).unwrap(); + assert_eq!(message.subject, "Synthetic \u{2713}"); + assert_eq!(message.conversation, "synthetic-thread"); + assert_eq!(message.received, "2030-01-01T00:00:00Z"); + assert_eq!(message.sender, "Synthetic Sender "); + assert_eq!(message.sender_address, "sender@example.invalid"); + assert_eq!(message.cc, ["cc@example.invalid"]); + assert_eq!( + message.web_link, + "https://mail.google.com/mail/u/0/#all/synthetic-id" + ); + assert!(message.sent); + assert_eq!(message.to, ["to@example.invalid"]); + } + + #[test] + fn item_bounds_subject_sender_name_and_addresses() { + let fixture = |subject: String, from: String| { + serde_json::json!({ + "id":"synthetic-id", "threadId":"synthetic-thread", + "internalDate":"1893456000000", + "payload":{"mimeType":"text/plain","headers":[ + {"name":"Subject","value":subject}, + {"name":"From","value":from} + ],"body":{"data":"Zm9v"}} + }) + }; + for value in [ + fixture("x".repeat(8193), "sender@example.invalid".into()), + fixture( + "Synthetic".into(), + format!("{} ", "x".repeat(4097)), + ), + fixture( + "Synthetic".into(), + format!("Synthetic <{}@example.invalid>", "x".repeat(500)), + ), + ] { + assert!(matches!( + item(&value), + Err(ConnectionError::ResponseTooLarge) + )); + } + } + + #[test] + fn malformed_body_is_a_message_error_only() { + let identity = + serde_json::json!({"sub":"synthetic-sub","email":"user@example.invalid"}).to_string(); + let listing = + serde_json::json!({"messages":[{"id":"synthetic-id","threadId":"synthetic-thread"}]}) + .to_string(); + let message = serde_json::json!({"id":"synthetic-id","threadId":"synthetic-thread","internalDate":"1893456000000","payload":{"mimeType":"text/plain","body":{"data":"!"}}}).to_string(); + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity)), + ("/gmail/v1/users/me/messages?", response("200 OK", &listing)), + ( + "/gmail/v1/users/me/messages/synthetic-id", + response("200 OK", &message), + ), + ]); + let http = Client::builder().no_proxy().build().unwrap(); + let filter = BTreeSet::from(["Gmail / Inbox".to_owned()]); + let sources = load_sources_from_origins( + &http, + "synthetic-token", + &MailCache::default(), + &LoadProgress::default(), + Some(&filter), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 3); + assert!(!sources[0].failed); + assert_eq!( + sources[0].message_errors, + [ConnectionError::ResourceUnavailable] + ); + } + + #[test] + fn routed_loads_both_labels_and_keeps_message_failures_local() { + let inbox = serde_json::json!({"messages":[ + {"id":"synthetic-html","threadId":"thread-html"}, + {"id":"synthetic-plain","threadId":"thread-plain"} + ]}) + .to_string(); + let sent = serde_json::json!({"messages":[ + {"id":"synthetic-sent","threadId":"thread-sent"}, + {"id":"synthetic-large","threadId":"thread-large"} + ]}) + .to_string(); + let html = serde_json::json!({ + "id":"synthetic-html","threadId":"thread-html","internalDate":"1893456000000","labelIds":["INBOX"], + "payload":{"mimeType":"multipart/alternative","headers":[ + {"name":"Subject","value":"Synthetic HTML"}, + {"name":"From","value":"Synthetic Sender "} + ],"parts":[ + {"mimeType":"text/plain","body":{"data":base64url_encode(b"Synthetic plain")}}, + {"mimeType":"text/html","body":{"data":base64url_encode(b"

Synthetic HTML

")}} + ]} + }).to_string(); + let plain = message_body( + "synthetic-plain", + "thread-plain", + &base64url_encode(b"Synthetic plain"), + "1893456000000", + ); + let sent_message = serde_json::json!({ + "id":"synthetic-sent","threadId":"thread-sent","internalDate":"1893456000000","labelIds":["SENT"], + "payload":{"mimeType":"text/plain","headers":[ + {"name":"Subject","value":"Synthetic sent"}, + {"name":"From","value":"sender@example.invalid"} + ],"body":{"data":base64url_encode(b"Synthetic sent body")}} + }).to_string(); + let oversized = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + super::super::super::MAX_RESPONSE + 1 + ) + .into_bytes(); + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity_body())), + ( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response("200 OK", &inbox), + ), + ( + "/gmail/v1/users/me/messages?labelIds=SENT", + response("200 OK", &sent), + ), + ( + "/gmail/v1/users/me/messages/synthetic-html", + response("200 OK", &html), + ), + ( + "/gmail/v1/users/me/messages/synthetic-plain", + response("200 OK", &plain), + ), + ( + "/gmail/v1/users/me/messages/synthetic-sent", + response("200 OK", &sent_message), + ), + ("/gmail/v1/users/me/messages/synthetic-large", oversized), + ]); + let http = Client::builder().no_proxy().build().unwrap(); + let sources = load_sources_from_origins( + &http, + "synthetic-token", + &MailCache::default(), + &LoadProgress::default(), + None, + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(sources.len(), 2); + assert_eq!(sources[0].messages.len(), 2); + assert_eq!(sources[0].messages[0].body, "

Synthetic HTML

"); + assert!(sources[0].messages[0].body_is_html); + assert_eq!(sources[0].messages[1].body, "Synthetic plain"); + assert!(!sources[0].failed); + assert_eq!( + sources[1].message_errors, + [ConnectionError::MessageTooLarge] + ); + assert_eq!(sources[1].messages.len(), 1); + assert!(sources[1].messages[0].sent); + assert!(!sources[1].failed); + } + + #[test] + fn paging_to_the_hundred_row_cap_is_partial() { + let first = (0..60) + .map(|index| serde_json::json!({"id":format!("synthetic-a-{index}"),"threadId":format!("thread-a-{index}")})) + .collect::>(); + let second = (0..50) + .map(|index| serde_json::json!({"id":format!("synthetic-b-{index}"),"threadId":format!("thread-b-{index}")})) + .collect::>(); + let page_one = + serde_json::json!({"messages":first,"nextPageToken":"synthetic-next"}).to_string(); + let page_two = + serde_json::json!({"messages":second,"nextPageToken":"synthetic-more"}).to_string(); + let specific = "/gmail/v1/users/me/messages?labelIds=INBOX&q=newer_than%3A30d&maxResults=100&pageToken=synthetic-next"; + let (origin, _, server) = routed_server(vec![ + (specific, response("200 OK", &page_two)), + ( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response("200 OK", &page_one), + ), + ]); + let http = Client::builder().no_proxy().build().unwrap(); + let (rows, partial) = list_rows(&http, "synthetic-token", &origin, "INBOX").unwrap(); + server.join().unwrap(); + assert_eq!(rows.len(), 100); + assert!(partial); + } + + #[test] + fn paging_stops_at_ten_pages_and_is_partial() { + let page = serde_json::json!({ + "messages":[{"id":"synthetic-id","threadId":"synthetic-thread"}], + "nextPageToken":"synthetic-next" + }) + .to_string(); + let routes = (0..10) + .map(|_| { + ( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response("200 OK", &page), + ) + }) + .collect(); + let (origin, calls, server) = routed_server(routes); + let http = Client::builder().no_proxy().build().unwrap(); + let (rows, partial) = list_rows(&http, "synthetic-token", &origin, "INBOX").unwrap(); + server.join().unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 10); + assert_eq!(rows.len(), 10); + assert!(partial); + } + + #[test] + fn hydrate_rejects_a_different_response_id() { + let body = message_body( + "different-id", + "synthetic-thread", + &base64url_encode(b"Synthetic body"), + "1893456000000", + ); + let (origin, _, server) = routed_server(vec![( + "/gmail/v1/users/me/messages/requested-id", + response("200 OK", &body), + )]); + let http = Client::builder().no_proxy().build().unwrap(); + let row = serde_json::json!({"id":"requested-id"}); + assert!(matches!( + hydrate(&http, "synthetic-token", &origin, &row), + Err(ConnectionError::ResourceUnavailable) + )); + server.join().unwrap(); + } + + #[test] + fn cache_hit_skips_fetch_and_cutoff_drops_old_message() { + let listing = serde_json::json!({"messages":[ + {"id":"synthetic-cached","threadId":"thread-cached"} + ]}) + .to_string(); + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity_body())), + ( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response("200 OK", &listing), + ), + ]); + let mut cache = MailCache::default(); + cache.insert( + ("google:synthetic-sub".into(), "synthetic-cached".into()), + MailItem { + provider: MailProvider::Google, + id: "synthetic-cached".into(), + conversation: "thread-cached".into(), + received: "2030-01-01T00:00:00Z".into(), + body: "Synthetic cached body".into(), + ..MailItem::default() + }, + ); + let http = Client::builder().no_proxy().build().unwrap(); + let filter = BTreeSet::from(["Gmail / Inbox".to_owned()]); + let sources = load_sources_from_origins( + &http, + "synthetic-token", + &cache, + &LoadProgress::default(), + Some(&filter), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 2); + assert_eq!(sources[0].messages[0].body, "Synthetic cached body"); + + let old_listing = serde_json::json!({"messages":[ + {"id":"synthetic-old","threadId":"thread-old"} + ]}) + .to_string(); + let old = message_body( + "synthetic-old", + "thread-old", + &base64url_encode(b"Synthetic old"), + "946684800000", + ); + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity_body())), + ( + "/gmail/v1/users/me/messages?labelIds=INBOX", + response("200 OK", &old_listing), + ), + ( + "/gmail/v1/users/me/messages/synthetic-old", + response("200 OK", &old), + ), + ]); + let sources = load_sources_from_origins( + &http, + "synthetic-token", + &MailCache::default(), + &LoadProgress::default(), + Some(&filter), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert!(sources[0].messages.is_empty()); + assert!(sources[0].message_errors.is_empty()); + } +} diff --git a/crates/openloops-graph/src/live/google/mime.rs b/crates/openloops-graph/src/live/google/mime.rs new file mode 100644 index 0000000..f57d446 --- /dev/null +++ b/crates/openloops-graph/src/live/google/mime.rs @@ -0,0 +1,375 @@ +use serde_json::Value; + +use crate::live::ConnectionError; + +const BODY_CHARS: usize = 131_072; + +pub(super) fn header<'a>(payload: &'a Value, name: &str) -> Option<&'a str> { + payload + .get("headers")? + .as_array()? + .iter() + .find(|entry| { + entry + .get("name") + .and_then(Value::as_str) + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + })? + .get("value")? + .as_str() +} + +fn attached(payload: &Value) -> bool { + payload + .get("filename") + .and_then(Value::as_str) + .is_some_and(|name| !name.is_empty()) + || header(payload, "Content-Disposition").is_some_and(|value| { + value + .trim_start() + .to_ascii_lowercase() + .starts_with("attachment") + }) +} + +fn collect_bodies( + payload: &Value, + html: &mut Option, + plain: &mut Option, +) -> Result<(), ConnectionError> { + if attached(payload) { + return Ok(()); + } + let mime = payload + .get("mimeType") + .and_then(Value::as_str) + .unwrap_or_default(); + let target = if mime.eq_ignore_ascii_case("text/html") { + Some(&mut *html) + } else if mime.eq_ignore_ascii_case("text/plain") { + Some(&mut *plain) + } else { + None + }; + if let Some(target) = target + && target.is_none() + && let Some(data) = payload.pointer("/body/data").and_then(Value::as_str) + { + *target = decode_part(data, header(payload, "Content-Type"))?; + } + if let Some(parts) = payload.get("parts").and_then(Value::as_array) { + for part in parts { + collect_bodies(part, html, plain)?; + } + } + Ok(()) +} + +pub(super) fn select_body(payload: &Value) -> Result, ConnectionError> { + let mut html = None; + let mut plain = None; + collect_bodies(payload, &mut html, &mut plain)?; + Ok(html + .map(|body| (body, true)) + .or_else(|| plain.map(|body| (body, false)))) +} + +fn charset(content_type: Option<&str>) -> &str { + content_type + .and_then(|value| { + value.split(';').skip(1).find_map(|parameter| { + let (name, value) = parameter.trim().split_once('=')?; + name.eq_ignore_ascii_case("charset") + .then(|| value.trim().trim_matches(['\'', '"'])) + }) + }) + .unwrap_or("utf-8") +} + +fn decode_bytes(bytes: &[u8], charset: &str) -> String { + if charset.eq_ignore_ascii_case("iso-8859-1") || charset.eq_ignore_ascii_case("latin1") { + bytes.iter().map(|&byte| char::from(byte)).collect() + } else if charset.eq_ignore_ascii_case("windows-1252") || charset.eq_ignore_ascii_case("cp1252") + { + bytes + .iter() + .map(|&byte| match byte { + 0x80 => '\u{20ac}', + 0x82 => '\u{201a}', + 0x83 => '\u{0192}', + 0x84 => '\u{201e}', + 0x85 => '\u{2026}', + 0x86 => '\u{2020}', + 0x87 => '\u{2021}', + 0x88 => '\u{02c6}', + 0x89 => '\u{2030}', + 0x8a => '\u{0160}', + 0x8b => '\u{2039}', + 0x8c => '\u{0152}', + 0x8e => '\u{017d}', + 0x91 => '\u{2018}', + 0x92 => '\u{2019}', + 0x93 => '\u{201c}', + 0x94 => '\u{201d}', + 0x95 => '\u{2022}', + 0x96 => '\u{2013}', + 0x97 => '\u{2014}', + 0x98 => '\u{02dc}', + 0x99 => '\u{2122}', + 0x9a => '\u{0161}', + 0x9b => '\u{203a}', + 0x9c => '\u{0153}', + 0x9e => '\u{017e}', + 0x9f => '\u{0178}', + other => char::from(other), + }) + .collect() + } else { + String::from_utf8_lossy(bytes).into_owned() + } +} + +pub(super) fn decode_part( + data: &str, + content_type: Option<&str>, +) -> Result, ConnectionError> { + let Some(bytes) = crate::encoding::base64url_decode(data.trim_end_matches('=')) else { + return Ok(None); + }; + let decoded = decode_bytes(&bytes, charset(content_type)); + if decoded.chars().count() > BODY_CHARS { + Err(ConnectionError::MessageTooLarge) + } else { + Ok(Some(decoded)) + } +} + +fn decode_word_bytes(word: &str) -> Option<(&str, Vec)> { + let inner = word.strip_prefix("=?")?.strip_suffix("?=")?; + let mut pieces = inner.splitn(3, '?'); + let charset = pieces.next()?; + let encoding = pieces.next()?; + let value = pieces.next()?; + if !charset.eq_ignore_ascii_case("utf-8") + && !charset.eq_ignore_ascii_case("iso-8859-1") + && !charset.eq_ignore_ascii_case("latin1") + { + return None; + } + let bytes = if encoding.eq_ignore_ascii_case("b") { + let normalized = value + .trim_end_matches('=') + .replace('+', "-") + .replace('/', "_"); + crate::encoding::base64url_decode(&normalized)? + } else if encoding.eq_ignore_ascii_case("q") { + let input = value.as_bytes(); + let mut output = Vec::with_capacity(input.len()); + let mut index = 0; + while index < input.len() { + match input[index] { + b'_' => output.push(b' '), + b'=' => { + let hex = std::str::from_utf8(input.get(index + 1..index + 3)?).ok()?; + output.push(u8::from_str_radix(hex, 16).ok()?); + index += 2; + } + byte => output.push(byte), + } + index += 1; + } + output + } else { + return None; + }; + Some((charset, bytes)) +} + +pub(super) fn decode_rfc2047(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while let Some(relative_start) = value[cursor..].find("=?") { + let start = cursor + relative_start; + output.push_str(&value[cursor..start]); + let Some(relative_end) = value[start + 2..].find("?=") else { + output.push_str(&value[start..]); + return output; + }; + let end = start + 2 + relative_end + 2; + let Some((first_charset, first_bytes)) = decode_word_bytes(&value[start..end]) else { + output.push_str(&value[start..end]); + cursor = end; + continue; + }; + + let mut groups = vec![(first_charset, first_bytes)]; + let mut run_end = end; + loop { + let whitespace_end = value[run_end..] + .char_indices() + .take_while(|(_, character)| character.is_whitespace()) + .last() + .map_or(run_end, |(index, character)| { + run_end + index + character.len_utf8() + }); + if whitespace_end == run_end || !value[whitespace_end..].starts_with("=?") { + break; + } + let Some(relative_end) = value[whitespace_end + 2..].find("?=") else { + break; + }; + let next_end = whitespace_end + 2 + relative_end + 2; + let Some((next_charset, next_bytes)) = + decode_word_bytes(&value[whitespace_end..next_end]) + else { + break; + }; + if let Some((charset, bytes)) = groups.last_mut() + && charset.eq_ignore_ascii_case(next_charset) + { + bytes.extend(next_bytes); + } else { + groups.push((next_charset, next_bytes)); + } + run_end = next_end; + } + for (charset, bytes) in groups { + output.push_str(&decode_bytes(&bytes, charset)); + } + cursor = run_end; + } + output.push_str(&value[cursor..]); + output +} + +pub(super) fn parse_address_list(value: &str) -> Vec<(Option, String)> { + let mut entries = Vec::new(); + let mut start = 0; + let mut quoted = false; + let mut angle_depth = 0u8; + for (index, character) in value.char_indices() { + match character { + '"' => quoted = !quoted, + '<' if !quoted => angle_depth = angle_depth.saturating_add(1), + '>' if !quoted => angle_depth = angle_depth.saturating_sub(1), + ',' if !quoted && angle_depth == 0 => { + parse_address(&value[start..index], &mut entries); + start = index + 1; + } + _ => {} + } + } + parse_address(&value[start..], &mut entries); + entries +} + +fn parse_address(value: &str, entries: &mut Vec<(Option, String)>) { + let value = value.trim(); + let (name, address) = if let Some((name, rest)) = value.split_once('<') { + ( + Some(name.trim().trim_matches('"')), + rest.trim_end_matches('>').trim(), + ) + } else { + (None, value) + }; + if address.is_empty() || address.chars().any(char::is_control) { + return; + } + entries.push(( + name.filter(|name| !name.is_empty()).map(decode_rfc2047), + address.to_ascii_lowercase(), + )); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_html_and_skips_attachments() { + let payload = serde_json::json!({"parts": [ + {"mimeType":"text/html","filename":"synthetic.html","body":{"data":"YmFk"}}, + {"mimeType":"multipart/alternative","parts":[ + {"mimeType":"text/plain","body":{"data":"cGxhaW4"}}, + {"mimeType":"text/html","headers":[{"name":"Content-Type","value":"text/html; charset=utf-8"}],"body":{"data":"PGI-aHRtbDwvYj4"}} + ]} + ]}); + assert_eq!( + select_body(&payload), + Ok(Some(("html".into(), true))) + ); + + let disposition = serde_json::json!({"parts": [ + {"mimeType":"text/html","headers":[{"name":"Content-Disposition","value":"attachment"}],"body":{"data":"YmFk"}}, + {"mimeType":"text/plain","body":{"data":"Z29vZA"}} + ]}); + assert_eq!(select_body(&disposition), Ok(Some(("good".into(), false)))); + } + + #[test] + fn decodes_charsets_headers_and_addresses() { + assert_eq!( + decode_part("Y2Fm6Q", Some("text/plain; charset=iso-8859-1")) + .unwrap() + .as_deref(), + Some("caf\u{e9}") + ); + assert_eq!( + decode_part("cHJpY2UgOJk", Some("text/plain; charset=windows-1252")) + .unwrap() + .as_deref(), + Some("price 8\u{2122}") + ); + assert_eq!( + decode_rfc2047("=?UTF-8?B?U3ludGhldGljIOKckw==?="), + "Synthetic \u{2713}" + ); + assert_eq!( + parse_address_list( + "\"Example, Sender\" , other@example.invalid" + ), + vec![ + ( + Some("Example, Sender".into()), + "sender@example.invalid".into() + ), + (None, "other@example.invalid".into()) + ] + ); + } + + #[test] + fn accepts_padded_and_unpadded_base64url_parts() { + assert_eq!(decode_part("Zm8=", None), Ok(Some("fo".into()))); + assert_eq!(decode_part("Zm9v", None), Ok(Some("foo".into()))); + } + + #[test] + fn oversized_and_malformed_bodies_are_distinct() { + let oversized = crate::encoding::base64url_encode("x".repeat(BODY_CHARS + 1).as_bytes()); + assert_eq!( + decode_part(&oversized, None), + Err(ConnectionError::MessageTooLarge) + ); + let oversized_payload = + serde_json::json!({"mimeType":"text/html","body":{"data":oversized}}); + assert_eq!( + select_body(&oversized_payload), + Err(ConnectionError::MessageTooLarge) + ); + assert_eq!(decode_part("!", None), Ok(None)); + let malformed = serde_json::json!({"mimeType":"text/plain","body":{"data":"!"}}); + assert_eq!(select_body(&malformed), Ok(None)); + } + + #[test] + fn rfc2047_joins_encoded_words_and_preserves_plain_whitespace() { + assert_eq!( + decode_rfc2047("=?UTF-8?Q?Synthetic_=E2=9C=93?="), + "Synthetic ✓" + ); + assert_eq!(decode_rfc2047("=?UTF-8?B?4pw=?= \t=?UTF-8?B?kw==?="), "✓"); + assert_eq!(decode_rfc2047("plain text"), "plain text"); + } +} diff --git a/crates/openloops-graph/src/live/google/mod.rs b/crates/openloops-graph/src/live/google/mod.rs index c2b1c1f..80a0f66 100644 --- a/crates/openloops-graph/src/live/google/mod.rs +++ b/crates/openloops-graph/src/live/google/mod.rs @@ -1,6 +1,52 @@ -//! Google provider configuration. Network support is added in a later phase. +//! Google OAuth configuration and read-only Gmail connection support. -use super::{ConnectionError, Secret}; +pub mod gmail; +mod mime; + +use std::collections::BTreeSet; +use std::sync::atomic::Ordering; + +use reqwest::blocking::Client; +use url::Url; + +use super::{ + ConnectionError, ConnectionReport, MailProvider, OAuthEndpoints, Secret, SharedScope, + authorize_with, graph_client, run_with_session_for, +}; +use crate::live::review::fetch_from_origin_with_headers; + +const IDENTITY: [&str; 3] = ["openid", "email", "profile"]; +const GMAIL_READ: &str = "https://www.googleapis.com/auth/gmail.readonly"; +const TASKS: &str = "https://www.googleapis.com/auth/tasks"; + +fn google_scope(scope: &str) -> String { + match scope { + "https://www.googleapis.com/auth/userinfo.email" => "email".to_owned(), + "https://www.googleapis.com/auth/userinfo.profile" => "profile".to_owned(), + _ => scope.to_owned(), + } +} + +pub(super) fn google_endpoints(config: &GoogleConfig) -> OAuthEndpoints { + OAuthEndpoints { + authorize: "https://accounts.google.com/o/oauth2/v2/auth", + token: "https://oauth2.googleapis.com/token", + redirect_host: super::callback::RedirectHost::LoopbackIp, + client_secret: Some(config.client_secret().clone()), + extra_params: &[ + ("access_type", "online"), + ("prompt", "select_account"), + ("include_granted_scopes", "true"), + ], + normalize_scope: google_scope, + } +} + +pub(super) fn required_scopes(reminders: bool) -> BTreeSet { + let mut scopes: BTreeSet = IDENTITY.map(str::to_owned).into_iter().collect(); + scopes.insert(if reminders { TASKS } else { GMAIL_READ }.to_owned()); + scopes +} /// Runtime Google OAuth registration values. Deliberately has no `Debug` implementation. pub struct GoogleConfig { @@ -8,6 +54,58 @@ pub struct GoogleConfig { client_secret: Secret, } +pub(super) fn with_google_session( + config: &GoogleConfig, + reminders: bool, + mut work: impl FnMut(&Client, &str) -> Result, +) -> Result { + if !valid_client_id(config.client_id()) || !valid_client_secret(config.client_secret().as_str()) + { + return Err(ConnectionError::InvalidConfiguration); + } + super::CONNECTING + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| ConnectionError::AlreadyConnecting)?; + let _guard = super::ConnectionGuard; + let http = graph_client()?; + let endpoints = google_endpoints(config); + run_with_session_for( + MailProvider::Google, + required_scopes(reminders), + |scopes| authorize_with(&endpoints, config.client_id(), scopes), + |token, _| work(&http, token), + ) +} + +fn check_from_origins( + http: &Client, + token: &str, + userinfo_origin: &str, + gmail_origin: &str, +) -> Result { + let _ = gmail::identity_from_origin(http, token, userinfo_origin)?; + let url = Url::parse(gmail_origin) + .and_then(|url| url.join("gmail/v1/users/me/profile")) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + let _ = fetch_from_origin_with_headers(http, token, &url, gmail_origin, &[])?; + Ok(ConnectionReport { + own_inbox_accessible: true, + shared_inbox_results: vec![], + shared_scope: SharedScope::NotReported, + group_inbox_results: vec![], + }) +} + +/// Checks OIDC identity and read-only Gmail profile access. +/// +/// # Errors +/// Returns only fixed, content-free configuration, authorization, or transport errors. +pub fn check_connection(config: &GoogleConfig) -> Result { + with_google_session(config, false, |http, token| { + check_from_origins(http, token, gmail::USERINFO_ORIGIN, gmail::GMAIL_ORIGIN) + }) +} + impl GoogleConfig { /// Validates Google desktop OAuth registration values without echoing them. /// @@ -30,6 +128,14 @@ impl GoogleConfig { pub(super) fn client_secret(&self) -> &Secret { &self.client_secret } + + #[cfg(test)] + pub(super) fn invalid_for_test() -> Self { + Self { + client_id: String::new(), + client_secret: Secret::new(String::new()), + } + } } pub(super) fn valid_client_id(value: &str) -> bool { @@ -54,6 +160,16 @@ pub(super) fn valid_client_secret(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::live::test_support::routed_server; + use std::time::{Duration, Instant}; + + fn response(status: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() + } #[test] fn validates_google_registration_without_exposing_values() { @@ -74,4 +190,97 @@ mod tests { GoogleConfig::new("123-synthetic.apps.googleusercontent.com", "bad\nvalue").is_err() ); } + + #[test] + fn scopes_and_endpoints_are_provider_specific() { + let mail = required_scopes(false); + assert!(IDENTITY.iter().all(|scope| mail.contains(*scope))); + assert!(mail.contains(GMAIL_READ)); + assert!(!mail.contains(TASKS)); + let tasks = required_scopes(true); + assert!(tasks.contains(TASKS)); + assert!(!tasks.contains(GMAIL_READ)); + + let config = GoogleConfig::new( + "123-synthetic.apps.googleusercontent.com", + "synthetic-client-parameter", + ) + .unwrap(); + let endpoints = google_endpoints(&config); + assert_eq!( + endpoints.authorize, + "https://accounts.google.com/o/oauth2/v2/auth" + ); + assert_eq!(endpoints.token, "https://oauth2.googleapis.com/token"); + assert_eq!( + endpoints.extra_params, + [ + ("access_type", "online"), + ("prompt", "select_account"), + ("include_granted_scopes", "true") + ] + ); + assert!(matches!( + endpoints.redirect_host, + super::super::callback::RedirectHost::LoopbackIp + )); + assert_eq!( + endpoints.client_secret.unwrap().as_str(), + "synthetic-client-parameter" + ); + } + + #[test] + fn google_granted_identity_scope_aliases_cover_required_scopes() { + let scopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + GMAIL_READ, + ] + .map(google_scope) + .into_iter() + .collect(); + let now = Instant::now(); + let session = super::super::Session { + access_token: Secret::new("synthetic-token".to_owned()), + expires_at: now + Duration::from_mins(2), + scopes, + shared: SharedScope::NotReported, + }; + assert!(super::super::session_covers( + &session, + &required_scopes(false), + now + )); + } + + #[test] + fn routed_connection_check_succeeds_and_maps_unauthorized() { + let identity = serde_json::json!({ + "sub":"synthetic-sub", + "email":"user@example.invalid" + }) + .to_string(); + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity)), + ("/gmail/v1/users/me/profile", response("200 OK", "{}")), + ]); + let http = Client::builder().no_proxy().build().unwrap(); + let report = check_from_origins(&http, "synthetic-token", &origin, &origin).unwrap(); + server.join().unwrap(); + assert!(report.own_inbox_accessible); + assert_eq!(calls.load(Ordering::Relaxed), 2); + + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", response("200 OK", &identity)), + ( + "/gmail/v1/users/me/profile", + response("401 Unauthorized", ""), + ), + ]); + let result = check_from_origins(&http, "synthetic-token", &origin, &origin); + server.join().unwrap(); + assert_eq!(result, Err(ConnectionError::Unauthorized)); + } } diff --git a/crates/openloops-graph/src/live/provider.rs b/crates/openloops-graph/src/live/provider.rs index 4f69b0a..d5abb86 100644 --- a/crates/openloops-graph/src/live/provider.rs +++ b/crates/openloops-graph/src/live/provider.rs @@ -98,10 +98,7 @@ impl AccountConfig { pub fn check_connection(&self) -> Result { match self { Self::Microsoft(config) => super::check_connection(config), - Self::Google(config) => { - let _ = (config.client_id(), config.client_secret()); - Err(ConnectionError::ProviderUnavailable) - } + Self::Google(config) => super::google::check_connection(config), } } @@ -132,8 +129,7 @@ impl AccountConfig { super::review::load_sources_with(config, cache, progress, filter) } Self::Google(config) => { - let _ = (config.client_id(), config.client_secret()); - Err(ConnectionError::ProviderUnavailable) + super::google::gmail::load_sources_with(config, cache, progress, filter) } } } @@ -185,21 +181,16 @@ mod tests { #[test] fn load_all_keeps_going_after_provider_error() { let accounts = [ - AccountConfig::Google( - GoogleConfig::new("123-fixture.apps.googleusercontent.com", "fixture-secret") - .unwrap(), - ), - AccountConfig::Google( - GoogleConfig::new("456-fixture.apps.googleusercontent.com", "fixture-secret") - .unwrap(), - ), + AccountConfig::Google(GoogleConfig::invalid_for_test()), + AccountConfig::Google(GoogleConfig::invalid_for_test()), ]; - let loads = load_all(&accounts, &MailCache::default(), &LoadProgress::default()); + let progress = LoadProgress::default(); + let loads = load_all(&accounts, &MailCache::default(), &progress); assert_eq!(loads.len(), 2); assert!( loads .iter() - .all(|load| matches!(load.sources, Err(ConnectionError::ProviderUnavailable))) + .all(|load| matches!(load.sources, Err(ConnectionError::InvalidConfiguration))) ); } } diff --git a/crates/openloops-graph/src/live/review.rs b/crates/openloops-graph/src/live/review.rs index 93eaa5e..73c55db 100644 --- a/crates/openloops-graph/src/live/review.rs +++ b/crates/openloops-graph/src/live/review.rs @@ -1051,10 +1051,12 @@ pub(super) fn add_hydrated( message.own_addresses.clone_from(&identity.addresses); message.own_display_name.clone_from(&identity.display_name); message.own_given_name.clone_from(&identity.given_name); - message.sent = sent; message.team = team; - if let Some(sent_time) = sent_time { - message.received = sent_time; + if identity.provider == MailProvider::Microsoft { + message.sent = sent; + if let Some(sent_time) = sent_time { + message.received = sent_time; + } } } if message.id.is_empty() || message.conversation.is_empty() { diff --git a/tools/check-public-repo.ps1 b/tools/check-public-repo.ps1 index d25fde3..088b211 100644 --- a/tools/check-public-repo.ps1 +++ b/tools/check-public-repo.ps1 @@ -170,7 +170,7 @@ $contentRules = @( }, [pscustomobject]@{ Name = 'personal Unix home-directory path' - Pattern = '(?i)/(?:Users|home)/(?!Shared/|runner/|sandbox/)[^/\s"''<>]+/' + Pattern = '(?i)/(?:Users|home)/(?!Shared/|runner/|sandbox/|me/|@me/)[^/\s"''<>]+/' AllowPlaceholders = $true }, [pscustomobject]@{ diff --git a/tools/test-public-repo.ps1 b/tools/test-public-repo.ps1 new file mode 100644 index 0000000..bf35360 --- /dev/null +++ b/tools/test-public-repo.ps1 @@ -0,0 +1,45 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$checker = Join-Path $PSScriptRoot 'check-public-repo.ps1' +$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$tempRoot = [IO.Path]::GetFullPath((Join-Path $tempBase ('openloops-public-' + [guid]::NewGuid().ToString('N')))) +if (-not $tempRoot.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe synthetic temp path.' } +[void](New-Item -ItemType Directory -Path $tempRoot) +$passed = 0 + +function Invoke-Case([string]$Text, [bool]$ShouldPass) { + $caseRoot = Join-Path $tempRoot ([guid]::NewGuid().ToString('N')) + [void](New-Item -ItemType Directory -Path $caseRoot) + & git -C $caseRoot init --quiet + if ($LASTEXITCODE -ne 0) { throw 'Could not initialize synthetic repository.' } + Set-Content -LiteralPath (Join-Path $caseRoot 'candidate.txt') -Value $Text -Encoding utf8NoBOM + & git -C $caseRoot add -- candidate.txt + if ($LASTEXITCODE -ne 0) { throw 'Could not stage synthetic candidate.' } + Push-Location $caseRoot + try { + $output = & pwsh -NoProfile -File $checker -Mode Staged 2>&1 | Out-String + $code = $LASTEXITCODE + } finally { + Pop-Location + } + if (($ShouldPass -and $code -ne 0) -or (-not $ShouldPass -and $code -eq 0)) { + throw "Public-repo synthetic case produced the wrong decision: $output" + } + $script:passed++ +} + +try { + Invoke-Case 'gmail/v1/users/me/messages' $true + Invoke-Case ('/' + 'Users/' + 'somebody/Documents/x') $false + Invoke-Case ('/' + 'home/' + 'somebody/.ssh/') $false + Write-Host "OpenLoops public-repo negative tests passed ($passed synthetic cases)." +} finally { + # Git marks its object files read-only, which [IO.Directory]::Delete refuses. + if (Test-Path -LiteralPath $tempRoot) { + Get-ChildItem -LiteralPath $tempRoot -Recurse -Force -File | ForEach-Object { $_.IsReadOnly = $false } + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue + } +}