diff --git a/crates/openloops-desktop/src/app_model.rs b/crates/openloops-desktop/src/app_model.rs index d70eb1e..f7721a5 100644 --- a/crates/openloops-desktop/src/app_model.rs +++ b/crates/openloops-desktop/src/app_model.rs @@ -1,22 +1,23 @@ //! Toolkit-free setup/connection state: job start/poll machinery, saved //! settings, and provider/account pure logic used by the native adapter. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::{Arc, atomic::Ordering}; use std::time::Instant; use crate::claim_view::LoopItem; +use crate::loop_state::Reminder; use crate::review_model::{CardContext, ConversationKey, ReviewState}; use crate::settings::{ ExtractionBackend, OllamaPlan, Provider, Settings, SettingsError, SettingsStore, max_parallel, production_store, }; use openloops_graph::live::{ - AccountConfig, ConnectionConfig, ConnectionError, ConnectionReport, MailProvider, - clear_all_sessions, + AccountConfig, ConnectionConfig, ConnectionError, ConnectionReport, MailProvider, ProviderLoad, + clear_all_sessions, clear_session_for, google::GoogleConfig, registration, - review::{LoadProgress, MailCache}, + review::{LoadProgress, MailCache, SourceReview}, }; use openloops_inference::decision::DecisionCheckReport; use openloops_inference::ollama::suggested_model; @@ -30,17 +31,17 @@ pub(crate) enum Outcome { ZdrModels(Result, ProviderError>), Generation(Result<(), ProviderError>), DecisionCheck(Result), - Mail(Result, ConnectionError>), + Mail(Vec), CheckMail { - sources: Result, ConnectionError>, + loads: Vec, }, Scan( Result, String, ), RetryMail { - sources: Result, ConnectionError>, - source_keys: BTreeSet, + loads: Vec, + source_keys: BTreeSet<(MailProvider, String)>, conversations: BTreeSet, attempted: usize, }, @@ -54,7 +55,11 @@ pub(crate) enum Outcome { conversations: BTreeSet, new_messages: usize, }, - Reminder([u8; 32], openloops_graph::live::reminders::ReminderOutcome), + Reminder( + [u8; 32], + MailProvider, + openloops_graph::live::reminders::ReminderOutcome, + ), ReminderCompletion( [u8; 32], openloops_graph::live::reminders::ReminderCompletionOutcome, @@ -103,7 +108,11 @@ pub struct AppModel { pub client_id: String, pub google_client_id: String, pub google_client_secret: Zeroizing, + /// Providers included in scans (saved). pub mail_providers: BTreeSet, + /// The provider whose fields the Sources card shows. UI-only, never + /// saved; `None` follows the first enabled provider. + pub(crate) viewed_mail_provider: Option, pub groups: String, pub shared: String, pub key: Zeroizing, @@ -166,6 +175,30 @@ struct ScanJobInputs { progress: Arc, } +/// Per-provider loads reduced to what a mail outcome handler acts on. +enum LoadSplit { + /// The single provider's error, or a stop by the user. + Failed(ConnectionError), + /// Every one of several providers failed; one prefixed line each. + AllFailed(Vec), + /// Sources from every provider that loaded. + Loaded(Vec), +} + +fn cache_entries( + sources: &[SourceReview], +) -> impl Iterator + '_ { + sources + .iter() + .flat_map(|source| &source.messages) + .map(|message| { + ( + (message.account.clone(), message.id.clone()), + message.clone(), + ) + }) +} + fn shared_registration_active_for(byo_field: &str, shipped: Option<&str>) -> bool { byo_field.trim().is_empty() && shipped.is_some() } @@ -207,6 +240,7 @@ impl AppModel { google_client_id: String::new(), google_client_secret: Zeroizing::new(String::new()), mail_providers: BTreeSet::from([MailProvider::Microsoft]), + viewed_mail_provider: None, groups: String::new(), shared: String::new(), key: Zeroizing::new(String::new()), @@ -273,6 +307,7 @@ impl AppModel { self.google_client_id = settings.google_client_id; self.google_client_secret = settings.google_client_secret; self.mail_providers = settings.mail_providers; + self.viewed_mail_provider = None; self.groups = settings.groups; self.shared = settings.shared; self.key = settings.key; @@ -480,8 +515,8 @@ impl AppModel { }; self.pending = None; match outcome { - Outcome::Reminder(key, outcome) => { - self.reminder_outcome(key, outcome); + Outcome::Reminder(key, provider, outcome) => { + self.reminder_outcome(key, provider, outcome); } Outcome::ReminderCompletion(key, outcome) => { self.reminder_completion_outcome(key, outcome); @@ -531,66 +566,61 @@ impl AppModel { Outcome::Connection(provider, result) => { *self.connection_status_mut(provider) = connection_report_status(provider, result); } - Outcome::Mail(Ok(sources)) => { - self.load_progress = None; - self.last_mail_check = Some(chrono::Utc::now().timestamp()); - self.mail_cache = Arc::new( - sources - .iter() - .flat_map(|source| &source.messages) - .map(|message| { - ( - (message.account.clone(), message.id.clone()), - message.clone(), - ) - }) - .collect(), - ); - self.review = ReviewState::loaded(sources); - if self.review.messages.is_empty() { - self.review_status = Status { lines: vec!["No messages could be loaded for analysis. Check scan coverage and errors.".into()], succeeded: false }; - } else { - self.start_scan(respawn.clone()); + Outcome::Mail(loads) => match self.split_loads(loads) { + LoadSplit::Failed(error) => { + self.load_progress = None; + self.review.scan_failed = error != ConnectionError::Cancelled; + self.review_status = Status { + lines: vec![error.to_string()], + succeeded: false, + }; } - } - Outcome::Mail(Err(error)) => { - self.load_progress = None; - self.review.scan_failed = error != ConnectionError::Cancelled; - self.review_status = Status { - lines: vec![error.to_string()], - succeeded: false, - }; - } - Outcome::CheckMail { sources } => { - self.check_mail_outcome(sources, respawn.clone()); + LoadSplit::AllFailed(lines) => { + self.load_progress = None; + self.review.scan_failed = true; + self.review_status = Status { + lines, + succeeded: false, + }; + } + LoadSplit::Loaded(sources) => { + self.load_progress = None; + self.last_mail_check = Some(chrono::Utc::now().timestamp()); + self.mail_cache = Arc::new(cache_entries(&sources).collect()); + self.review = ReviewState::loaded(sources); + if self.review.messages.is_empty() { + self.review_status = Status { lines: vec!["No messages could be loaded for analysis. Check scan coverage and errors.".into()], succeeded: false }; + } else { + self.start_scan(respawn.clone()); + } + } + }, + Outcome::CheckMail { loads } => { + self.check_mail_outcome(loads, respawn.clone()); } Outcome::RetryMail { - sources, - source_keys, + loads, + mut source_keys, mut conversations, attempted, } => { self.load_progress = None; - match sources { - Ok(sources) => { - Arc::make_mut(&mut self.mail_cache).extend( - sources - .iter() - .flat_map(|source| &source.messages) - .map(|message| { - ( - (message.account.clone(), message.id.clone()), - message.clone(), - ) - }), - ); - conversations.extend(self.review.replace_sources(&source_keys, sources)); - } - Err(ConnectionError::Cancelled) => { + let loaded: BTreeSet = loads + .iter() + .filter(|load| load.sources.is_ok()) + .map(|load| load.provider) + .collect(); + match self.split_loads(loads) { + LoadSplit::Failed(ConnectionError::Cancelled) => { self.finish_retry_status(attempted); return true; } - Err(_) => {} + LoadSplit::Failed(_) | LoadSplit::AllFailed(_) => {} + LoadSplit::Loaded(sources) => { + Arc::make_mut(&mut self.mail_cache).extend(cache_entries(&sources)); + source_keys.retain(|(provider, _)| loaded.contains(provider)); + conversations.extend(self.review.replace_sources(&source_keys, sources)); + } } if conversations.is_empty() { self.finish_retry_status(attempted); @@ -652,27 +682,69 @@ impl AppModel { true } + /// Splits per-provider loads. One load keeps today's single-provider + /// handling. With several, each failed provider gets one status line + /// prefixed with its service name, and the other providers' sources are + /// kept. A stop by the user cancels the whole job. + fn split_loads(&mut self, loads: Vec) -> LoadSplit { + if loads + .iter() + .any(|load| matches!(load.sources, Err(ConnectionError::Cancelled))) + { + return LoadSplit::Failed(ConnectionError::Cancelled); + } + for load in &loads { + if load.sources.is_ok() { + let prefix = format!("{}: ", load.provider.service_name()); + self.connection_status_mut(load.provider) + .lines + .retain(|line| !line.starts_with(&prefix)); + } + } + if loads.len() == 1 { + return match loads.into_iter().next().map(|load| load.sources) { + Some(Err(error)) => LoadSplit::Failed(error), + Some(Ok(sources)) => LoadSplit::Loaded(sources), + None => LoadSplit::Loaded(Vec::new()), + }; + } + let mut sources = Vec::new(); + let mut lines = Vec::new(); + let mut any_loaded = loads.is_empty(); + for load in loads { + match load.sources { + Ok(loaded) => { + any_loaded = true; + sources.extend(loaded); + } + Err(error) => { + let line = format!("{}: {error}", load.provider.service_name()); + *self.connection_status_mut(load.provider) = Status { + lines: vec![line.clone()], + succeeded: false, + }; + lines.push(line); + } + } + } + if any_loaded { + LoadSplit::Loaded(sources) + } else { + LoadSplit::AllFailed(lines) + } + } + fn check_mail_outcome( &mut self, - sources: Result, ConnectionError>, + loads: Vec, respawn: impl FnOnce() + Send + 'static, ) { self.load_progress = None; let previous_check = self.last_mail_check; self.last_mail_check = Some(chrono::Utc::now().timestamp()); - match sources { - Ok(sources) => { - Arc::make_mut(&mut self.mail_cache).extend( - sources - .iter() - .flat_map(|source| &source.messages) - .map(|message| { - ( - (message.account.clone(), message.id.clone()), - message.clone(), - ) - }), - ); + match self.split_loads(loads) { + LoadSplit::Loaded(sources) => { + Arc::make_mut(&mut self.mail_cache).extend(cache_entries(&sources)); let known: BTreeSet = self .review .messages @@ -703,18 +775,24 @@ impl AppModel { self.start_check_scan(changed, new_messages, prior, respawn); } } - Err(ConnectionError::Cancelled) => { + LoadSplit::Failed(ConnectionError::Cancelled) => { self.review_status = Status { lines: vec!["Check stopped; no new mail was added.".into()], succeeded: false, }; } - Err(error) => { + LoadSplit::Failed(error) => { self.review_status = Status { lines: vec![error.to_string()], succeeded: false, }; } + LoadSplit::AllFailed(lines) => { + self.review_status = Status { + lines, + succeeded: false, + }; + } } } @@ -773,15 +851,16 @@ impl AppModel { fn reminder_outcome( &mut self, key: [u8; 32], + provider: MailProvider, outcome: openloops_graph::live::reminders::ReminderOutcome, ) { use crate::loop_state::{Reminder, now}; use openloops_graph::live::reminders::ReminderOutcome; let mut record = self.review.decisions.get(&key); let (text, outcome_succeeded)=match outcome { - ReminderOutcome::Created{list_id,task_id}=>{record.reminder=Reminder::Created{provider: MailProvider::Microsoft,list_id,task_id}; ("Reminder created in your Microsoft To Do Tasks list.".to_owned(), true)}, - ReminderOutcome::NotCreated(reason)=>{record.reminder=Reminder::None;(format!("No reminder was created: {reason}"), false)}, - ReminderOutcome::Uncertain=>("Microsoft did not confirm the write. Check To Do before trying again; OpenLoops will not automatically retry.".to_owned(), false), + ReminderOutcome::Created{provider,list_id,task_id}=>{record.reminder=Reminder::Created{provider,list_id,task_id}; (match provider { MailProvider::Microsoft => "Reminder created in your Microsoft To Do Tasks list.".to_owned(), MailProvider::Google => "Reminder created in your Google Tasks default list.".to_owned() }, true)}, + ReminderOutcome::NotCreated(reason)=>{record.reminder=Reminder::None;(format!("No reminder was created: {}", reason.describe(provider)), false)}, + ReminderOutcome::Uncertain=>(match provider { MailProvider::Microsoft => "Microsoft did not confirm the write. Check To Do before trying again; OpenLoops will not automatically retry.".to_owned(), MailProvider::Google => "Google did not confirm the write. Check Google Tasks before trying again; OpenLoops will not automatically retry.".to_owned() }, false), }; record.updated = now(); let saved = self.review.decisions.update(record); @@ -792,42 +871,56 @@ impl AppModel { }; } - /// Reports whether the already-Handled card's linked To Do task was also + /// Reports whether the already-Handled card's linked task was also /// marked complete. Never reverts the local `Done` decision or the /// `Reminder::Created` record on failure -- see `reminders::complete()`'s /// doc comment -- so this only ever updates the status line, distinct /// from `reminder_outcome`, which can revert a reminder to `None`. fn reminder_completion_outcome( &mut self, - _key: [u8; 32], + key: [u8; 32], outcome: openloops_graph::live::reminders::ReminderCompletionOutcome, ) { use openloops_graph::live::reminders::ReminderCompletionOutcome; + let provider = match self.review.decisions.get(&key).reminder { + Reminder::Created { provider, .. } | Reminder::Completed { provider, .. } => provider, + Reminder::None | Reminder::Attempted => MailProvider::Microsoft, + }; self.review.action_status = match outcome { ReminderCompletionOutcome::Completed => { - "Decision saved on this Windows account. The linked Microsoft To Do task was also marked complete.".to_owned() + format!( + "Decision saved on this Windows account. The linked {} task was also marked complete.", + provider.tasks_name() + ) } ReminderCompletionOutcome::NotCompleted(error) => { format!( - "Decision saved on this Windows account. The linked Microsoft To Do task was not marked complete: {error}" + "Decision saved on this Windows account. The linked {} task was not marked complete: {error}", + provider.tasks_name() ) } - ReminderCompletionOutcome::Uncertain => { - "Decision saved on this Windows account. Microsoft did not confirm the To Do task was marked complete; check it there.".to_owned() - } + ReminderCompletionOutcome::Uncertain => match provider { + MailProvider::Microsoft => { + "Decision saved on this Windows account. Microsoft did not confirm the To Do task was marked complete; check it there.".to_owned() + } + MailProvider::Google => { + "Decision saved on this Windows account. Google did not confirm the Google Tasks task was marked complete; check it there.".to_owned() + } + }, }; self.review.action_status_succeeded = matches!(outcome, ReminderCompletionOutcome::Completed); } /// Applies the reverse direction of `reminder_completion_outcome`: a - /// task the user completed directly in Microsoft To Do, discovered by + /// task the user completed directly in Microsoft To Do or Google Tasks, + /// discovered by /// `dispatch_reminder_sync`, marks its loop Handled in `OpenLoops`. /// `NotCompleted`/`Unknown` results change nothing -- a task that's /// still open, or one `OpenLoops` simply couldn't check this time, is not /// evidence of anything; `Reminder::Completed` (rather than leaving it /// `Created`) is what lets `status_pill_hint` say this loop was closed - /// by the To Do task specifically, not by the user clicking Handled. + /// by the linked task specifically, not by the user clicking Handled. fn reminder_sync_outcome( &mut self, results: Vec<( @@ -838,6 +931,7 @@ impl AppModel { use crate::loop_state::{Decision, Reminder}; use openloops_graph::live::reminders::TaskStatusOutcome; let mut completed = 0usize; + let mut providers = BTreeSet::new(); for (key, outcome) in results { if !matches!(outcome, TaskStatusOutcome::Completed) { continue; @@ -860,11 +954,16 @@ impl AppModel { record.updated = crate::loop_state::now(); if self.review.decisions.update(record).is_ok() { completed += 1; + providers.insert(provider); } } if completed > 0 { + let linked = match (providers.len(), providers.first()) { + (1, Some(provider)) => format!("linked {} task", provider.tasks_name()), + _ => "linked task".to_owned(), + }; self.review.scan_errors.push(format!( - "{completed} loop(s) marked handled because their linked Microsoft To Do task was already completed." + "{completed} loop(s) marked handled because their {linked} was already completed." )); } } @@ -954,7 +1053,9 @@ impl AppModel { /// the model owning any UI-nav state. #[must_use] pub fn ready_for_review(&self) -> bool { - self.registration_present(self.active_mail_provider()) + self.enabled_mail_providers() + .into_iter() + .any(|provider| self.registration_present(provider)) && !self.active_key().is_empty() && !self.selected_model().is_empty() } @@ -966,28 +1067,140 @@ impl AppModel { } } + /// The providers included in scans, in [`MailProvider::ALL`] order. + #[must_use] + pub fn enabled_mail_providers(&self) -> Vec { + MailProvider::ALL + .into_iter() + .filter(|provider| self.mail_providers.contains(provider)) + .collect() + } + + /// Includes or excludes `provider` from scans and saves the choice. + /// Disabling clears that provider's session, status and mail only. + /// Refuses to disable the last enabled provider; returns whether the + /// change was applied. + pub fn set_provider_enabled(&mut self, provider: MailProvider, enabled: bool) -> bool { + if enabled { + self.settings_status = Status::default(); + if self.mail_providers.insert(provider) { + self.pending_save = true; + let _ = self.persist_changes(); + } + return true; + } + if !self.mail_providers.contains(&provider) { + return true; + } + if self.mail_providers.len() == 1 { + self.settings_status = Status { + lines: vec!["Keep at least one mail provider enabled.".into()], + succeeded: false, + }; + return false; + } + self.settings_status = Status::default(); + self.mail_providers.remove(&provider); + clear_session_for(provider); + *self.connection_status_mut(provider) = Status::default(); + self.review.remove_provider(provider); + Arc::make_mut(&mut self.mail_cache).retain(|_, message| message.provider != provider); + self.pending_save = true; + let _ = self.persist_changes(); + true + } + + /// The provider whose fields the Sources card shows: the user's pick, + /// else the first enabled provider. Sources card only; the Review pane + /// uses [`AppModel::primary_mail_provider`]. #[must_use] pub fn active_mail_provider(&self) -> MailProvider { - self.mail_providers - .iter() - .next() + self.viewed_mail_provider + .unwrap_or_else(|| self.primary_mail_provider()) + } + + /// The first enabled provider. The Review pane's default when no card + /// names a provider. + #[must_use] + pub fn primary_mail_provider(&self) -> MailProvider { + self.enabled_mail_providers() + .first() .copied() .unwrap_or(MailProvider::Microsoft) } + /// Shows `provider`'s fields on the Sources card. Changes no enablement. pub fn set_active_mail_provider(&mut self, provider: MailProvider) { - if self.mail_providers == BTreeSet::from([provider]) { - return; + self.viewed_mail_provider = Some(provider); + } + + /// One configuration per enabled provider. A provider with a missing or + /// invalid registration is skipped and gets a status line, unless no + /// provider is usable. + /// + /// # Errors + /// Returns the first provider's error when no enabled provider is usable. + pub fn enabled_accounts(&mut self) -> Result, ConnectionError> { + let mut accounts = Vec::new(); + let mut failures = Vec::new(); + for provider in self.enabled_mail_providers() { + match self.account_config(provider) { + Ok(account) => accounts.push(account), + Err(error) => failures.push((provider, error)), + } } - 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(); + if accounts.is_empty() { + return Err(failures + .into_iter() + .next() + .map_or(ConnectionError::InvalidConfiguration, |(_, error)| error)); + } + for (provider, error) in failures { + *self.connection_status_mut(provider) = Status { + lines: vec![format!("{}: {error}", provider.service_name())], + succeeded: false, + }; + } + Ok(accounts) + } + + /// The retry job for each provider with failed sources: its + /// configuration and the labels to load again. A provider with a missing + /// or invalid registration is skipped and gets a status line, unless no + /// provider is usable. + /// + /// # Errors + /// Returns the first provider's configuration error when no provider is + /// usable. + pub fn retry_jobs( + &mut self, + failed_sources: &BTreeSet<(MailProvider, String)>, + ) -> Result)>, ConnectionError> { + let mut grouped = BTreeMap::>::new(); + for (provider, label) in failed_sources { + grouped.entry(*provider).or_default().insert(label.clone()); + } + let mut jobs = Vec::new(); + let mut failures = Vec::new(); + for (provider, labels) in grouped { + match self.account_config(provider) { + Ok(account) => jobs.push((account, labels)), + Err(error) => failures.push((provider, error)), + } + } + if jobs.is_empty() && !failures.is_empty() { + return Err(failures + .into_iter() + .next() + .map_or(ConnectionError::InvalidConfiguration, |(_, error)| error)); + } + for (provider, error) in failures { + *self.connection_status_mut(provider) = Status { + lines: vec![format!("{}: {error}", provider.service_name())], + succeeded: false, + }; + } + Ok(jobs) } #[must_use] @@ -1039,6 +1252,28 @@ impl AppModel { } } + /// Builds the configuration for a reminder write or check. Microsoft + /// reminders use the personal To Do list only, so the configuration has + /// no shared mailbox and no groups (those inputs are for mail loading). + /// + /// # Errors + /// Returns [`ConnectionError::InvalidConfiguration`] when required + /// registration values are missing or malformed. + pub fn reminder_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, None).map(AccountConfig::Microsoft) + } + MailProvider::Google => self.account_config(MailProvider::Google), + } + } + #[must_use] pub fn admin_consent_url(&self) -> Option { self.effective_microsoft_client_id() @@ -1095,7 +1330,7 @@ impl AppModel { } /// After a scan, checks whether any still-open, reminder-bearing - /// decision's linked Microsoft To Do task was completed outside + /// decision's linked Microsoft To Do or Google Tasks task was completed outside /// `OpenLoops` -- the reverse direction of `on_review_decision`'s /// complete-on-Handled hook (`slint_review.rs`). Runs after /// `Outcome::Scan` rather than before the mail download, because @@ -1106,39 +1341,33 @@ impl AppModel { /// way to recover it. A no-op when nothing is eligible: only a /// `Mine`/`Watching`/`Review` decision with a `Reminder::Created` /// record carrying real (non-empty) ids has anything left to learn from - /// Graph. + /// its provider. pub(crate) fn dispatch_reminder_sync(&mut self) { - if self.active_mail_provider() != MailProvider::Microsoft { + let jobs = self.reminder_sync_jobs(); + if jobs.is_empty() { return; } - let Some(analysis) = &self.review.analysis else { - return; - }; - let items = analysis.items.clone(); - let cards = self.review.card_contexts(&items); - let checks = self.review.reminder_sync_checks(&items, &cards); - if checks.is_empty() { - return; - } - let Some(client_id) = self.effective_microsoft_client_id() else { - return; - }; - let Ok(config) = ConnectionConfig::new(&client_id, None) else { - return; - }; + let providers = jobs + .iter() + .map(|(config, _)| config.provider()) + .collect::>(); self.start( Service::Review, - "Checking Microsoft To Do for completed reminders", + reminder_sync_job_label(&providers), move || { - let results = checks + let results = jobs .into_iter() - .map(|(account, key, list_id, task_id)| { - ( - key, - openloops_graph::live::reminders::check_status( - &config, &account, &list_id, &task_id, - ), - ) + .flat_map(|(config, checks)| { + checks + .into_iter() + .map(move |(account, key, list_id, task_id)| { + ( + key, + openloops_graph::live::provider::reminder_status( + &config, &account, &list_id, &task_id, + ), + ) + }) }) .collect(); Outcome::ReminderSync(results) @@ -1259,12 +1488,58 @@ impl AppModel { }; } - /// How many still-open, reminder-bearing decisions a "Sync To Do" click + /// The reminder status checks `dispatch_reminder_sync` would run, grouped + /// by provider (Microsoft first), each with its reminder configuration. + /// A provider whose configuration is missing or invalid is left out. + pub(crate) fn reminder_sync_jobs(&self) -> Vec<(AccountConfig, Vec)> { + let Some(analysis) = &self.review.analysis else { + return Vec::new(); + }; + let items = &analysis.items; + let cards = self.review.card_contexts(items); + let mut grouped = BTreeMap::>::new(); + for (provider, account, key, list_id, task_id) in + self.review.reminder_sync_checks(items, &cards) + { + grouped + .entry(provider) + .or_default() + .push((account, key, list_id, task_id)); + } + grouped + .into_iter() + .filter_map(|(provider, checks)| { + self.reminder_account_config(provider) + .ok() + .map(|config| (config, checks)) + }) + .collect() + } + + /// The providers of the reminder records a sync-button click would check. + #[must_use] + pub(crate) fn reminder_sync_providers( + &self, + cards: &[Option], + ) -> BTreeSet { + self.review + .analysis + .as_ref() + .map_or_else(BTreeSet::new, |analysis| { + self.review + .reminder_sync_checks(&analysis.items, cards) + .into_iter() + .map(|(provider, ..)| provider) + .collect() + }) + } + + /// How many still-open, reminder-bearing decisions a sync-button click /// would check right now, given `cards` the caller already computed /// (typically the same `card_contexts` result a `sync`/`sync_review` /// pass already built) -- see `ReviewState::reminder_sync_checks`. /// Drives the Review toolbar button's enabled state and its "Checking N - /// To Do task(s)..." status line. Never calls `card_contexts` itself. + /// ... task(s)..." status line. Never calls `card_contexts` itself. #[must_use] pub(crate) fn reminder_sync_eligible_count(&self, cards: &[Option]) -> usize { self.review.analysis.as_ref().map_or(0, |analysis| { @@ -1281,6 +1556,21 @@ impl Default for AppModel { } } +/// One reminder status check: account, decision key, list id, task id. +pub(crate) type ReminderSyncCheck = (String, [u8; 32], String, String); + +/// The progress label for a reminder sync over `providers`. +pub(crate) fn reminder_sync_job_label(providers: &BTreeSet) -> &'static str { + match ( + providers.contains(&MailProvider::Microsoft), + providers.contains(&MailProvider::Google), + ) { + (true, true) => "Checking reminders for completed tasks", + (false, true) => "Checking Google Tasks for completed reminders", + _ => "Checking Microsoft To Do for completed reminders", + } +} + pub(crate) fn connection_report_status( provider: MailProvider, result: Result, @@ -1406,14 +1696,10 @@ impl AccountDisplay { #[cfg(test)] mod tests { use super::*; - use openloops_graph::live::{ - SharedScope, - review::{MailItem, SourceReview}, - }; + use openloops_graph::live::{SharedScope, review::MailItem}; use openloops_inference::decision::DecisionCheckReport; use std::{ cell::{Cell, RefCell}, - collections::HashMap, rc::Rc, }; @@ -1796,43 +2082,59 @@ mod tests { ); } + const SYNTHETIC_MS_ID: &str = "00000000-0000-4000-8000-000000000000"; + const SYNTHETIC_GOOGLE_ID: &str = "123-synthetic.apps.googleusercontent.com"; + + fn mail_item(provider: MailProvider, account: &str, id: &str) -> MailItem { + MailItem { + provider, + id: id.into(), + account: account.into(), + conversation: format!("thread-{id}"), + received: "not-a-timestamp".into(), + ..MailItem::default() + } + } + + fn provider_source( + provider: MailProvider, + label: &str, + messages: Vec, + ) -> SourceReview { + SourceReview { + provider, + label: label.into(), + messages, + errors: vec![], + message_errors: vec![], + partial: false, + failed: false, + } + } + + fn load( + provider: MailProvider, + sources: Result, ConnectionError>, + ) -> openloops_graph::live::ProviderLoad { + openloops_graph::live::ProviderLoad { provider, sources } + } + #[test] - fn active_mail_provider_defaults_and_switch_persists_a_single_provider() { + fn enabled_providers_follow_all_order_and_persist() { 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.enabled_mail_providers(), [MailProvider::Microsoft]); + assert!(app.set_provider_enabled(MailProvider::Google, true)); + assert_eq!( + app.enabled_mail_providers(), + [MailProvider::Microsoft, MailProvider::Google] + ); assert_eq!( - app.google_client_id, - "123-synthetic.apps.googleusercontent.com" + memory.saved.borrow().as_ref().unwrap().mail_providers, + BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]) ); - assert_eq!(&*app.google_client_secret, "synthetic-client-parameter"); + assert!(app.set_provider_enabled(MailProvider::Microsoft, false)); + assert_eq!(app.enabled_mail_providers(), [MailProvider::Google]); assert_eq!( memory.saved.borrow().as_ref().unwrap().mail_providers, BTreeSet::from([MailProvider::Google]) @@ -1840,64 +2142,464 @@ mod tests { } #[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() + fn disabling_the_last_enabled_provider_is_refused() { + let mut app = AppModel::with_store(Ok(None)); + assert!(!app.set_provider_enabled(MailProvider::Microsoft, false)); + assert_eq!(app.enabled_mail_providers(), [MailProvider::Microsoft]); + assert_eq!( + app.settings_status.lines, + ["Keep at least one mail provider enabled."] ); - assert!(effective_google_registration_for("", "synthetic", shipped).is_none()); + assert!(!app.settings_status.succeeded); + + // A later successful toggle clears the refusal line. + assert!(app.set_provider_enabled(MailProvider::Google, true)); + assert!(app.settings_status.lines.is_empty()); + app.settings_status = Status { + lines: vec!["Keep at least one mail provider enabled.".into()], + succeeded: false, + }; + assert!(app.set_provider_enabled(MailProvider::Google, false)); + assert!(app.settings_status.lines.is_empty()); } #[test] - fn google_account_config_requires_both_valid_synthetic_fields() { + fn disabling_a_provider_removes_only_its_mail_and_status() { 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(_)) - )); + app.mail_providers = BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + app.microsoft.lines = vec!["Personal inbox: access confirmed.".into()]; + app.google.lines = vec!["Personal inbox: access confirmed.".into()]; + app.review = crate::review_model::layout_fixture(); + let microsoft_messages = app.review.messages.len(); + Arc::make_mut(&mut app.mail_cache).extend([ + ( + ("synthetic".into(), "synthetic-ms".into()), + mail_item(MailProvider::Microsoft, "synthetic", "synthetic-ms"), + ), + ( + ("google:synthetic-sub".into(), "synthetic-g".into()), + mail_item(MailProvider::Google, "google:synthetic-sub", "synthetic-g"), + ), + ]); + + assert!(app.set_provider_enabled(MailProvider::Google, false)); + + assert!(app.google.lines.is_empty()); + assert_eq!(app.microsoft.lines, ["Personal inbox: access confirmed."]); + assert_eq!(app.review.messages.len(), microsoft_messages); + assert!(app.review.analysis.is_some()); + assert_eq!(app.mail_cache.len(), 1); + assert!( + app.mail_cache + .contains_key(&("synthetic".into(), "synthetic-ms".into())) + ); + + assert!(app.set_provider_enabled(MailProvider::Google, true)); + assert_eq!(app.microsoft.lines, ["Personal inbox: access confirmed."]); + assert_eq!(app.review.messages.len(), microsoft_messages); } #[test] - fn shared_registration_is_active_only_for_an_empty_byo_field_when_shipped() { - assert!(shared_registration_active_for("", Some("synthetic"))); - assert!(shared_registration_active_for(" \t", Some("synthetic"))); - assert!(!shared_registration_active_for( - "synthetic-byo", - Some("synthetic") - )); - assert!(!shared_registration_active_for("", None)); + fn viewed_mail_provider_is_a_ui_selection_only() { + let memory = MemoryStore::default(); + let mut app = AppModel::with_store(Ok(Some(Box::new(memory.clone())))); + app.review = crate::review_model::layout_fixture(); + assert_eq!(app.active_mail_provider(), MailProvider::Microsoft); + app.mail_providers = BTreeSet::from([MailProvider::Google]); + assert_eq!(app.active_mail_provider(), MailProvider::Google); + app.mail_providers = BTreeSet::from([MailProvider::Microsoft]); + + app.set_active_mail_provider(MailProvider::Google); + + assert_eq!(app.active_mail_provider(), MailProvider::Google); + assert_eq!(app.enabled_mail_providers(), [MailProvider::Microsoft]); + assert!(app.review.analysis.is_some()); + assert!(memory.saved.borrow().is_none()); } #[test] - fn empty_byo_without_a_shipped_registration_has_no_microsoft_registration() { - if registration::microsoft().is_some() { + fn enabled_accounts_skip_a_provider_without_registration() { + let mut app = AppModel::with_store(Ok(None)); + if registration::google().is_some() { return; } - let app = AppModel::with_store(Ok(None)); - assert!(app.client_id.is_empty()); + app.client_id = SYNTHETIC_MS_ID.into(); + app.mail_providers = BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + let accounts = app.enabled_accounts().unwrap(); + assert_eq!( + accounts + .iter() + .map(AccountConfig::provider) + .collect::>(), + [MailProvider::Microsoft] + ); + assert_eq!( + app.google.lines, + [format!("Google: {}", ConnectionError::InvalidConfiguration)] + ); + assert!(app.microsoft.lines.is_empty()); + + app.google_client_id = SYNTHETIC_GOOGLE_ID.into(); + app.google_client_secret = Zeroizing::new("synthetic-client-parameter".into()); + assert_eq!(app.enabled_accounts().unwrap().len(), 2); + } + + #[test] + fn enabled_accounts_without_any_registration_fail_without_status_lines() { + let mut app = AppModel::with_store(Ok(None)); + if registration::microsoft().is_some() { + return; + } + assert!(matches!( + app.enabled_accounts(), + Err(ConnectionError::InvalidConfiguration) + )); + assert!(app.microsoft.lines.is_empty()); + } + + #[test] + fn mail_outcome_keeps_the_ok_provider_and_records_the_failed_one() { + let mut app = AppModel::with_store(Ok(None)); + app.load_progress = Some(Arc::new(LoadProgress::default())); + app.microsoft.lines = vec!["Personal inbox: access confirmed.".into()]; + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + app.pending_service = Service::Review; + sender + .send(Outcome::Mail(vec![ + load( + MailProvider::Microsoft, + Ok(vec![provider_source( + MailProvider::Microsoft, + "Personal mailbox / Inbox", + vec![mail_item( + MailProvider::Microsoft, + "synthetic", + "synthetic-a", + )], + )]), + ), + load( + MailProvider::Google, + Err(ConnectionError::ProviderUnavailable), + ), + ])) + .unwrap(); + assert!(app.poll(|| {})); + assert!(app.load_progress.is_none()); + assert!( + app.mail_cache + .contains_key(&("synthetic".into(), "synthetic-a".into())) + ); + assert!( + app.review + .notices + .iter() + .any(|note| note.starts_with("Personal mailbox / Inbox")) + ); + assert_eq!( + app.google.lines, + [format!("Google: {}", ConnectionError::ProviderUnavailable)] + ); + assert!(!app.google.succeeded); + assert_eq!(app.microsoft.lines, ["Personal inbox: access confirmed."]); + } + + #[test] + fn single_provider_mail_error_reports_on_the_review_status_as_before() { + let mut app = AppModel::with_store(Ok(None)); + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + app.pending_service = Service::Review; + sender + .send(Outcome::Mail(vec![load( + MailProvider::Microsoft, + Err(ConnectionError::ProviderUnavailable), + )])) + .unwrap(); + assert!(app.poll(|| {})); + assert_eq!( + app.review_status.lines, + [ConnectionError::ProviderUnavailable.to_string()] + ); + assert!(app.review.scan_failed); + assert!(app.microsoft.lines.is_empty()); + } + + #[test] + fn both_providers_failing_report_each_prefixed_line() { + let mut app = AppModel::with_store(Ok(None)); + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + app.pending_service = Service::Review; + sender + .send(Outcome::Mail(vec![ + load(MailProvider::Microsoft, Err(ConnectionError::AccessDenied)), + load( + MailProvider::Google, + Err(ConnectionError::ProviderUnavailable), + ), + ])) + .unwrap(); + assert!(app.poll(|| {})); + assert_eq!( + app.review_status.lines, + [ + format!("Microsoft 365: {}", ConnectionError::AccessDenied), + format!("Google: {}", ConnectionError::ProviderUnavailable), + ] + ); + assert!(app.review.scan_failed); + } + + #[test] + fn check_mail_with_mixed_loads_keeps_ok_mail_and_records_the_error() { + let mut app = AppModel::with_store(Ok(None)); + app.review = crate::review_model::layout_fixture(); + app.last_mail_check = Some(0); + let known = MailItem { + id: "synthetic-0".into(), + account: "synthetic".into(), + conversation: "synthetic-thread".into(), + subject: "Quarterly planning".into(), + body: "Synthetic cached body.".into(), + received: "2026-09-06T12:00:00Z".into(), + ..MailItem::default() + }; + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + sender + .send(Outcome::CheckMail { + loads: vec![ + load(MailProvider::Microsoft, Ok(vec![check_source(vec![known])])), + load(MailProvider::Google, Err(ConnectionError::Unauthorized)), + ], + }) + .unwrap(); + assert!(app.poll(|| {})); + assert!( + app.mail_cache + .contains_key(&("synthetic".into(), "synthetic-0".into())) + ); + assert!(app.review_status.lines[0].starts_with("No new mail since ")); + assert_eq!( + app.google.lines, + [format!("Google: {}", ConnectionError::Unauthorized)] + ); + } + + #[test] + fn a_successful_load_removes_that_providers_earlier_load_error() { + let mut app = AppModel::with_store(Ok(None)); + app.review = crate::review_model::layout_fixture(); + app.last_mail_check = Some(0); + let known = MailItem { + id: "synthetic-0".into(), + account: "synthetic".into(), + conversation: "synthetic-thread".into(), + subject: "Quarterly planning".into(), + body: "Synthetic cached body.".into(), + received: "2026-09-06T12:00:00Z".into(), + ..MailItem::default() + }; + for google in [Err(ConnectionError::Unauthorized), Ok(vec![])] { + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + sender + .send(Outcome::CheckMail { + loads: vec![ + load( + MailProvider::Microsoft, + Ok(vec![check_source(vec![known.clone()])]), + ), + load(MailProvider::Google, google), + ], + }) + .unwrap(); + assert!(app.poll(|| {})); + } + assert!( + !app.google + .lines + .iter() + .any(|line| line.starts_with("Google: ")) + ); + } + + #[test] + fn retry_jobs_group_failed_labels_by_provider() { + let mut app = AppModel::with_store(Ok(None)); + app.client_id = SYNTHETIC_MS_ID.into(); + app.google_client_id = SYNTHETIC_GOOGLE_ID.into(); + app.google_client_secret = Zeroizing::new("synthetic-client-parameter".into()); + app.mail_providers = BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + let failed = BTreeSet::from([ + ( + MailProvider::Microsoft, + "Personal mailbox / Inbox".to_owned(), + ), + (MailProvider::Google, "Gmail / Sent".to_owned()), + (MailProvider::Google, "Gmail / Inbox".to_owned()), + ]); + let jobs = app.retry_jobs(&failed).unwrap(); + assert_eq!( + jobs.iter() + .map(|(account, labels)| (account.provider(), labels.clone())) + .collect::>(), + [ + ( + MailProvider::Microsoft, + BTreeSet::from(["Personal mailbox / Inbox".to_owned()]) + ), + ( + MailProvider::Google, + BTreeSet::from(["Gmail / Inbox".to_owned(), "Gmail / Sent".to_owned()]) + ), + ] + ); + } + + #[test] + fn retry_jobs_skip_a_provider_without_registration() { + let mut app = AppModel::with_store(Ok(None)); + if registration::google().is_some() { + return; + } + app.client_id = SYNTHETIC_MS_ID.into(); + app.mail_providers = BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + let failed = BTreeSet::from([ + ( + MailProvider::Microsoft, + "Personal mailbox / Inbox".to_owned(), + ), + (MailProvider::Google, "Gmail / Inbox".to_owned()), + ]); + let jobs = app.retry_jobs(&failed).unwrap(); + assert_eq!( + jobs.iter() + .map(|(account, _)| account.provider()) + .collect::>(), + [MailProvider::Microsoft] + ); + assert_eq!( + app.google.lines, + [format!("Google: {}", ConnectionError::InvalidConfiguration)] + ); + assert!(app.microsoft.lines.is_empty()); + + app.client_id.clear(); + if registration::microsoft().is_none() { + assert!(matches!( + app.retry_jobs(&failed), + Err(ConnectionError::InvalidConfiguration) + )); + } + } + + #[test] + fn retry_mail_routes_replacements_by_provider_label() { + let mut app = AppModel::with_store(Ok(None)); + app.review.failed_sources = BTreeSet::from([ + ( + MailProvider::Microsoft, + "Personal mailbox / Inbox".to_owned(), + ), + (MailProvider::Google, "Gmail / Inbox".to_owned()), + ]); + let (sender, receiver) = mpsc::channel(); + app.pending = Some(receiver); + sender + .send(Outcome::RetryMail { + loads: vec![ + load( + MailProvider::Microsoft, + Ok(vec![provider_source( + MailProvider::Microsoft, + "Personal mailbox / Inbox", + vec![], + )]), + ), + load( + MailProvider::Google, + Err(ConnectionError::ProviderUnavailable), + ), + ], + source_keys: app.review.failed_sources.clone(), + conversations: BTreeSet::new(), + attempted: 2, + }) + .unwrap(); + assert!(app.poll(|| {})); + assert_eq!( + app.review.failed_sources, + BTreeSet::from([(MailProvider::Google, "Gmail / Inbox".to_owned())]) + ); + assert_eq!(app.review_status.lines, ["Retried 2; 1 still failing."]); + assert_eq!( + app.google.lines, + [format!("Google: {}", ConnectionError::ProviderUnavailable)] + ); + } + + #[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"))); + assert!(shared_registration_active_for(" \t", Some("synthetic"))); + assert!(!shared_registration_active_for( + "synthetic-byo", + Some("synthetic") + )); + assert!(!shared_registration_active_for("", None)); + } + + #[test] + fn empty_byo_without_a_shipped_registration_has_no_microsoft_registration() { + if registration::microsoft().is_some() { + return; + } + let app = AppModel::with_store(Ok(None)); + assert!(app.client_id.is_empty()); assert!(app.effective_microsoft_client_id().is_none()); assert!(app.admin_consent_url().is_none()); assert!(!app.shared_registration_active()); @@ -1936,7 +2638,12 @@ mod tests { let (sender, receiver) = mpsc::channel(); app.pending = Some(receiver); app.pending_service = Service::Review; - sender.send(Outcome::Mail(Ok(sources))).unwrap(); + sender + .send(Outcome::Mail(vec![load( + MailProvider::Microsoft, + Ok(sources), + )])) + .unwrap(); assert!(app.poll(|| {})); assert!(app.load_progress.is_none()); assert_eq!(app.mail_cache.len(), 2); @@ -1960,7 +2667,10 @@ mod tests { app.pending = Some(receiver); app.pending_service = Service::Review; sender - .send(Outcome::Mail(Err(ConnectionError::Cancelled))) + .send(Outcome::Mail(vec![load( + MailProvider::Microsoft, + Err(ConnectionError::Cancelled), + )])) .unwrap(); assert!(app.poll(|| {})); assert!(app.load_progress.is_none()); @@ -1981,7 +2691,7 @@ mod tests { app.pending = Some(receiver); sender .send(Outcome::CheckMail { - sources: Ok(Vec::new()), + loads: vec![load(MailProvider::Microsoft, Ok(Vec::new()))], }) .unwrap(); assert!(app.poll(|| {})); @@ -2011,7 +2721,10 @@ mod tests { app.pending = Some(receiver); sender .send(Outcome::CheckMail { - sources: Err(ConnectionError::Cancelled), + loads: vec![load( + MailProvider::Microsoft, + Err(ConnectionError::Cancelled), + )], }) .unwrap(); assert!(app.poll(|| {})); @@ -2078,7 +2791,10 @@ mod tests { app.pending = Some(receiver); sender .send(Outcome::CheckMail { - sources: Ok(vec![check_source(vec![known])]), + loads: vec![load( + MailProvider::Microsoft, + Ok(vec![check_source(vec![known])]), + )], }) .unwrap(); assert!(app.poll(|| {})); @@ -2365,15 +3081,226 @@ mod tests { assert!(app.pending.is_none()); } + fn set_fixture_reminder(app: &mut AppModel, card: usize, provider: MailProvider) { + use crate::loop_state::{Decision, Record, now}; + let items = app.review.analysis.as_ref().unwrap().items.clone(); + let cards = app.review.card_contexts(&items); + let key = cards[card].as_ref().unwrap().record.key; + app.review + .decisions + .update(Record { + key, + decision: Decision::Mine, + reminder: Reminder::Created { + provider, + list_id: format!("synthetic-list-{card}"), + task_id: format!("synthetic-task-{card}"), + }, + updated: now(), + }) + .unwrap(); + } + + fn reminder_registrations(app: &mut AppModel) { + app.client_id = "00000000-0000-4000-8000-000000000000".into(); + app.google_client_id = "123-synthetic.apps.googleusercontent.com".into(); + app.google_client_secret = Zeroizing::new("synthetic-client-parameter".into()); + } + + // The planner is tested rather than `dispatch_reminder_sync` itself: + // starting the job would run a real browser sign-in on a worker thread. #[test] - fn dispatch_reminder_sync_is_a_noop_for_google() { + fn google_reminder_with_valid_google_config_plans_one_sync_job() { let mut app = AppModel::with_store(Ok(None)); + reminder_registrations(&mut app); app.review = crate::review_model::layout_fixture(); - app.mail_providers = BTreeSet::from([MailProvider::Google]); + set_fixture_reminder(&mut app, 0, MailProvider::Google); - app.dispatch_reminder_sync(); + let jobs = app.reminder_sync_jobs(); - assert!(app.pending.is_none()); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].0.provider(), MailProvider::Google); + assert_eq!(jobs[0].1.len(), 1); + assert_eq!( + reminder_sync_job_label(&BTreeSet::from([MailProvider::Google])), + "Checking Google Tasks for completed reminders" + ); + } + + #[test] + fn microsoft_and_google_reminders_plan_two_sync_groups() { + let mut app = AppModel::with_store(Ok(None)); + reminder_registrations(&mut app); + app.review = crate::review_model::layout_fixture(); + set_fixture_reminder(&mut app, 0, MailProvider::Microsoft); + set_fixture_reminder(&mut app, 1, MailProvider::Google); + + let jobs = app.reminder_sync_jobs(); + + let providers = jobs + .iter() + .map(|(config, checks)| (config.provider(), checks.len())) + .collect::>(); + assert_eq!( + providers, + vec![(MailProvider::Microsoft, 1), (MailProvider::Google, 1)] + ); + } + + #[test] + fn reminder_sync_job_label_names_the_providers_checked() { + assert_eq!( + reminder_sync_job_label(&BTreeSet::from([MailProvider::Microsoft])), + "Checking Microsoft To Do for completed reminders" + ); + assert_eq!( + reminder_sync_job_label(&BTreeSet::from([MailProvider::Google])), + "Checking Google Tasks for completed reminders" + ); + assert_eq!( + reminder_sync_job_label(&BTreeSet::from([ + MailProvider::Microsoft, + MailProvider::Google + ])), + "Checking reminders for completed tasks" + ); + } + + #[test] + fn microsoft_reminder_config_ignores_mail_loading_shared_and_group_inputs() { + let mut app = AppModel::with_store(Ok(None)); + reminder_registrations(&mut app); + app.shared = "not a mailbox".into(); + app.groups = "not a group".into(); + assert!(app.account_config(MailProvider::Microsoft).is_err()); + assert!(app.reminder_account_config(MailProvider::Microsoft).is_ok()); + assert!(app.reminder_account_config(MailProvider::Google).is_ok()); + } + + #[test] + fn reminder_completion_copy_is_per_provider() { + use openloops_graph::live::reminders::ReminderCompletionOutcome; + let mut app = AppModel::with_store(Ok(None)); + app.review = crate::review_model::layout_fixture(); + set_fixture_reminder(&mut app, 0, MailProvider::Microsoft); + set_fixture_reminder(&mut app, 1, MailProvider::Google); + let items = app.review.analysis.as_ref().unwrap().items.clone(); + let cards = app.review.card_contexts(&items); + let microsoft = cards[0].as_ref().unwrap().record.key; + let google = cards[1].as_ref().unwrap().record.key; + let cases = [ + ( + microsoft, + ReminderCompletionOutcome::Completed, + "Decision saved on this Windows account. The linked Microsoft To Do task was also marked complete.".to_owned(), + ), + ( + microsoft, + ReminderCompletionOutcome::NotCompleted(ConnectionError::NotFound), + format!( + "Decision saved on this Windows account. The linked Microsoft To Do task was not marked complete: {}", + ConnectionError::NotFound + ), + ), + ( + microsoft, + ReminderCompletionOutcome::Uncertain, + "Decision saved on this Windows account. Microsoft did not confirm the To Do task was marked complete; check it there.".to_owned(), + ), + ( + google, + ReminderCompletionOutcome::Completed, + "Decision saved on this Windows account. The linked Google Tasks task was also marked complete.".to_owned(), + ), + ( + google, + ReminderCompletionOutcome::NotCompleted(ConnectionError::NotFound), + format!( + "Decision saved on this Windows account. The linked Google Tasks task was not marked complete: {}", + ConnectionError::NotFound + ), + ), + ( + google, + ReminderCompletionOutcome::Uncertain, + "Decision saved on this Windows account. Google did not confirm the Google Tasks task was marked complete; check it there.".to_owned(), + ), + ]; + for (key, outcome, expected) in cases { + app.reminder_completion_outcome(key, outcome); + assert_eq!(app.review.action_status, expected); + } + } + + #[test] + fn reminder_create_success_and_uncertain_copy_is_per_provider() { + use openloops_graph::live::reminders::ReminderOutcome; + let mut app = AppModel::with_store(Ok(None)); + app.reminder_outcome( + [9_u8; 32], + MailProvider::Microsoft, + ReminderOutcome::Created { + provider: MailProvider::Microsoft, + list_id: "synthetic-list".into(), + task_id: "synthetic-task".into(), + }, + ); + assert_eq!( + app.review.action_status, + "Reminder created in your Microsoft To Do Tasks list." + ); + app.reminder_outcome( + [10_u8; 32], + MailProvider::Microsoft, + ReminderOutcome::Uncertain, + ); + assert_eq!( + app.review.action_status, + "Microsoft did not confirm the write. Check To Do before trying again; OpenLoops will not automatically retry." + ); + app.reminder_outcome( + [11_u8; 32], + MailProvider::Google, + ReminderOutcome::Uncertain, + ); + assert_eq!( + app.review.action_status, + "Google did not confirm the write. Check Google Tasks before trying again; OpenLoops will not automatically retry." + ); + } + + #[test] + fn reminder_sync_outcome_copy_is_per_provider() { + use openloops_graph::live::reminders::TaskStatusOutcome; + for (providers, expected) in [ + ( + vec![MailProvider::Microsoft], + "1 loop(s) marked handled because their linked Microsoft To Do task was already completed.", + ), + ( + vec![MailProvider::Google], + "1 loop(s) marked handled because their linked Google Tasks task was already completed.", + ), + ( + vec![MailProvider::Microsoft, MailProvider::Google], + "2 loop(s) marked handled because their linked task was already completed.", + ), + ] { + let mut app = AppModel::with_store(Ok(None)); + app.review = crate::review_model::layout_fixture(); + let items = app.review.analysis.as_ref().unwrap().items.clone(); + let mut results = Vec::new(); + for (card, provider) in providers.iter().enumerate() { + set_fixture_reminder(&mut app, card, *provider); + let cards = app.review.card_contexts(&items); + results.push(( + cards[card].as_ref().unwrap().record.key, + TaskStatusOutcome::Completed, + )); + } + app.reminder_sync_outcome(results); + assert_eq!(app.review.scan_errors.last().unwrap(), expected); + } } #[test] @@ -2411,12 +3338,53 @@ mod tests { let mut app = AppModel::with_store(Ok(None)); let mut key = [0_u8; 32]; key[0] = u8::try_from(index).unwrap(); - app.reminder_outcome(key, ReminderOutcome::NotCreated(reason)); + app.reminder_outcome( + key, + MailProvider::Microsoft, + ReminderOutcome::NotCreated(reason), + ); assert_eq!(app.review.action_status, expected); assert!(!app.review.action_status_succeeded); } } + #[test] + fn google_reminder_outcomes_use_google_copy_and_preserve_provider() { + use openloops_graph::live::reminders::{ReminderFailure, ReminderOutcome}; + let mut app = AppModel::with_store(Ok(None)); + let created_key = [7_u8; 32]; + app.reminder_outcome( + created_key, + MailProvider::Google, + ReminderOutcome::Created { + provider: MailProvider::Google, + list_id: "synthetic-list".into(), + task_id: "synthetic-task".into(), + }, + ); + assert_eq!( + app.review.action_status, + "Reminder created in your Google Tasks default list." + ); + assert!(matches!( + app.review.decisions.get(&created_key).reminder, + Reminder::Created { + provider: MailProvider::Google, + .. + } + )); + + app.reminder_outcome( + [8_u8; 32], + MailProvider::Google, + ReminderOutcome::NotCreated(ReminderFailure::DefaultListNotFound), + ); + assert_eq!( + app.review.action_status, + "No reminder was created: Google Tasks did not return a default list for this account. Open Google Tasks once, then try again." + ); + } + #[test] fn reminder_sync_eligible_count_matches_what_dispatch_would_check() { use crate::loop_state::{Decision, Record, Reminder, now}; diff --git a/crates/openloops-desktop/src/loop_state.rs b/crates/openloops-desktop/src/loop_state.rs index a2e0d7a..04c658d 100644 --- a/crates/openloops-desktop/src/loop_state.rs +++ b/crates/openloops-desktop/src/loop_state.rs @@ -25,9 +25,9 @@ const CAP: usize = 55; /// unbounded, so the `CAP` rejection in `update()` stays rare under ordinary /// use while still bounding the store's size. const TERMINAL_EXPIRY_SECONDS: i64 = 180 * 86400; -/// Bound on each Graph id stored in `Reminder::Created`, chosen to fit a -/// single-byte length prefix (real Microsoft Graph To Do list/task ids are -/// consistently well under this). Given the same 2560-byte credential-blob +/// Bound on each provider task-list or task id stored in `Reminder::Created`, +/// chosen to fit a single-byte length prefix (real Microsoft To Do and Google +/// Tasks list/task ids are consistently well under this). Given the same 2560-byte credential-blob /// ceiling `CAP` is sized against, storing two such ids alongside every /// record is not something a raised `CAP` could also absorb; a save that /// would exceed the blob size still fails safely via `encode()`'s existing @@ -53,8 +53,8 @@ pub enum Reminder { #[default] None, Attempted, - /// Carries the Graph task-list and task id `reminders::create()` - /// resolved, so a later action (marking it complete once the card is + /// Carries the provider plus the task-list and task id its adapter's + /// `create()` resolved, so a later action (marking it complete once the card is /// Handled) can address the same task -- see `reminders::complete()`. /// Each id is bounded to `MAX_REMINDER_ID_LEN` bytes for storage; see /// that constant for why. @@ -63,8 +63,9 @@ pub enum Reminder { list_id: String, task_id: String, }, - /// Set only by the reminder-sync check (`reminders::check_status`) - /// finding the linked task already completed in Microsoft To Do -- + /// Set only by the reminder-sync check (`provider::reminder_status`) + /// finding the linked task already completed in Microsoft To Do or + /// Google Tasks -- /// never by the user clicking Handled, which leaves `reminder` as /// `Created` even after `reminders::complete()` succeeds. This is the /// one thing that distinguishes "the user marked this handled" from @@ -393,7 +394,7 @@ pub fn marker(key: &[u8; 32]) -> String { }) } -/// Appends one Graph id as a single-byte length prefix followed by its +/// Appends one provider task-list or task id as a single-byte length prefix followed by its /// bytes. Fails rather than silently truncating an id over /// `MAX_REMINDER_ID_LEN` -- callers must reject or shorten it upstream /// instead (see `reminders::valid_graph_id`, which already bounds ids this diff --git a/crates/openloops-desktop/src/review_model.rs b/crates/openloops-desktop/src/review_model.rs index d7fdbb0..e3a0d26 100644 --- a/crates/openloops-desktop/src/review_model.rs +++ b/crates/openloops-desktop/src/review_model.rs @@ -46,6 +46,7 @@ pub(crate) fn reset_card_contexts_call_count() { pub(crate) struct ReminderDraft { pub(crate) key: [u8; 32], + pub(crate) provider: MailProvider, pub(crate) account: String, pub(crate) title: String, pub(crate) when: String, @@ -139,7 +140,7 @@ pub struct ReviewState { pub scan_errors: Vec, pub scan_incomplete: bool, pub source_failures: usize, - pub failed_sources: BTreeSet, + pub failed_sources: BTreeSet<(MailProvider, String)>, pub failed_conversations_detail: Vec, pub decisions: Decisions, pub relations: Relations, @@ -190,6 +191,12 @@ pub struct ReviewState { pub(crate) scan_failed: bool, source_notes: BTreeMap>, source_issue_counts: BTreeMap, + /// The provider of each source label seen, so one provider's diagnostics + /// can be dropped without parsing labels. + source_providers: BTreeMap, + /// The next unused message-handle index. Stays above every handle given + /// out, so handles stay unique after [`ReviewState::remove_provider`]. + next_handle: usize, conversation_quality: BTreeMap, conversation_notes_by_id: BTreeMap>, conversation_rejection_reasons: BTreeMap>, @@ -225,6 +232,8 @@ impl Default for ReviewState { scan_failed: false, source_notes: BTreeMap::new(), source_issue_counts: BTreeMap::new(), + source_providers: BTreeMap::new(), + next_handle: 0, conversation_quality: BTreeMap::new(), conversation_notes_by_id: BTreeMap::new(), conversation_rejection_reasons: BTreeMap::new(), @@ -348,11 +357,14 @@ impl ReviewState { let mut issue_count = usize::from(source.partial) + source.errors.len() + source.message_errors.len(); let mut prepare_failures = std::collections::BTreeMap::new(); + let failed_key = (source.provider, label); if source.failed { - self.failed_sources.insert(label.clone()); + self.failed_sources.insert(failed_key.clone()); } else { - self.failed_sources.remove(&label); + self.failed_sources.remove(&failed_key); } + let (provider, label) = failed_key; + self.source_providers.insert(label.clone(), provider); for item in &source.messages { if self .messages @@ -361,8 +373,10 @@ impl ReviewState { { continue; } - match scanning::prepare(item, &label, self.messages.len()) { + let index = self.next_handle.max(self.messages.len()); + match scanning::prepare(item, &label, index) { Ok(m) => { + self.next_handle = index + 1; appended_handles.insert(m.input.handle.clone()); self.messages.push(m); } @@ -459,16 +473,75 @@ impl ReviewState { pub fn replace_sources( &mut self, - source_keys: &BTreeSet, + source_keys: &BTreeSet<(MailProvider, String)>, sources: Vec, ) -> BTreeSet { for key in source_keys { self.failed_sources.remove(key); - self.source_notes.remove(key); - self.source_issue_counts.remove(key); + self.source_notes.remove(&key.1); + self.source_issue_counts.remove(&key.1); } self.append_sources(sources) } + + /// Drops one provider's messages, cards and source diagnostics, keyed by + /// each message's provider. Saved decisions are not touched. + pub fn remove_provider(&mut self, provider: MailProvider) { + let removed: BTreeSet = self + .messages + .iter() + .filter(|message| message.provider == provider) + .map(|message| message.input.handle.clone()) + .collect(); + self.messages.retain(|message| message.provider != provider); + if let Some(analysis) = &mut self.analysis { + analysis + .items + .retain(|item| !removed.contains(&item.evidence.message)); + } + self.failed_sources.retain(|(owner, _)| *owner != provider); + let labels: Vec = self + .source_providers + .iter() + .filter(|(_, owner)| **owner == provider) + .map(|(label, _)| label.clone()) + .collect(); + for label in &labels { + self.source_providers.remove(label); + self.source_notes.remove(label); + self.source_issue_counts.remove(label); + } + let live: BTreeSet<(&str, &str)> = self + .messages + .iter() + .map(|message| (message.account.as_str(), message.conversation.as_str())) + .collect(); + self.failed_conversations_detail.retain(|failure| { + live.contains(&(failure.account.as_str(), failure.conversation.as_str())) + }); + let keep = |key: &ConversationKey| live.contains(&(key.0.as_str(), key.1.as_str())); + self.conversation_quality.retain(|key, _| keep(key)); + self.conversation_notes_by_id.retain(|key, _| keep(key)); + self.conversation_rejection_reasons + .retain(|key, _| keep(key)); + self.notices = self.source_notes.values().flatten().cloned().collect(); + self.source_failures = self.source_issue_counts.values().sum(); + self.scan_summary = format!( + "{} mail removed from this review. Scan inboxes to refresh the counts.", + provider.service_name() + ); + self.scan_errors.clear(); + self.scan_incomplete = + self.source_failures > 0 || !self.failed_conversations_detail.is_empty(); + if self + .draft + .as_ref() + .is_some_and(|draft| draft.provider == provider) + && let Some(draft) = self.draft.take() + { + self.revert_draft_decision(draft.prior_decision, draft.key); + } + } pub fn set_scan(&mut self, result: ScanResult, model: String) { self.scan_failed = false; self.conversation_quality = result.conversation_quality.clone(); @@ -1079,7 +1152,7 @@ impl ReviewState { ) } - /// Still-open, reminder-bearing decisions eligible for a To Do sync + /// Still-open, reminder-bearing decisions eligible for a reminder sync /// right now: a `Mine`/`Watching`/`Review` decision with a /// `Reminder::Created` record carrying real (non-empty) ids, matched /// back to the account that carries evidence for it. `cards` is the @@ -1087,15 +1160,15 @@ impl ReviewState { /// recomputed here so a caller that already has it (every `sync`/ /// `sync_review` pass) never pays for a second HMAC fingerprint pass /// just to learn how many cards are eligible. Shared by - /// `AppModel::dispatch_reminder_sync` (the actual Graph call) and - /// `AppModel::reminder_sync_eligible_count` (the Review toolbar's "Sync - /// To Do" button), so the two can never disagree about what counts as + /// `AppModel::dispatch_reminder_sync` (the actual provider calls) and + /// `AppModel::reminder_sync_eligible_count` (the Review toolbar's sync + /// button), so the two can never disagree about what counts as /// eligible. pub(crate) fn reminder_sync_checks( &self, items: &[LoopItem], cards: &[Option], - ) -> Vec<(String, [u8; 32], String, String)> { + ) -> Vec<(MailProvider, String, [u8; 32], String, String)> { items .iter() .zip(cards.iter()) @@ -1108,7 +1181,7 @@ impl ReviewState { return None; } let Reminder::Created { - provider: MailProvider::Microsoft, + provider, list_id, task_id, } = &card.record.reminder @@ -1123,6 +1196,7 @@ impl ReviewState { .iter() .find(|m| m.input.handle == item.evidence.message)?; Some(( + *provider, source.account.clone(), card.record.key, list_id.clone(), @@ -1875,6 +1949,7 @@ pub fn layout_fixture() -> ReviewState { }); state.draft = Some(ReminderDraft { key: second_key, + provider: MailProvider::Microsoft, account: "synthetic".into(), title: "Confirm who will send the team budget".into(), when: default_reminder(), @@ -2023,12 +2098,187 @@ mod tests { assert_eq!(state.messages.len(), 1); } + fn google_source(failed: bool, ids: &[&str]) -> SourceReview { + SourceReview { + provider: MailProvider::Google, + label: "Gmail / Inbox".into(), + messages: ids + .iter() + .map(|id| openloops_graph::live::review::MailItem { + provider: MailProvider::Google, + account: "google:synthetic-sub".into(), + ..test_mail( + id, + &format!("thread-{id}"), + "p9@example.invalid", + vec!["user@example.invalid"], + ) + }) + .collect(), + errors: vec![], + message_errors: vec![], + partial: false, + failed, + } + } + + #[test] + fn failed_sources_are_keyed_by_provider_and_label() { + let mut state = ReviewState::loaded(vec![google_source(true, &[])]); + assert_eq!( + state.failed_sources, + BTreeSet::from([(MailProvider::Google, "Gmail / Inbox".to_owned())]) + ); + state.append_sources(vec![google_source(false, &[])]); + assert!(state.failed_sources.is_empty()); + } + + #[test] + fn remove_provider_drops_only_that_providers_messages_and_cards() { + let mut state = layout_fixture(); + let microsoft_handles: Vec = state + .messages + .iter() + .map(|message| message.input.handle.clone()) + .collect(); + let items_before = state.analysis.as_ref().unwrap().items.len(); + let decisions_before = state.decisions.records.clone(); + state.append_sources(vec![google_source(false, &["g1"])]); + let google_handle = state + .messages + .iter() + .find(|message| message.provider == MailProvider::Google) + .unwrap() + .input + .handle + .clone(); + let mut google_item = state.analysis.as_ref().unwrap().items[0].clone(); + google_item.evidence.message.clone_from(&google_handle); + state.analysis.as_mut().unwrap().items.push(google_item); + state + .failed_sources + .insert((MailProvider::Google, "Gmail / Sent".into())); + assert!( + state + .notices + .iter() + .any(|note| note.starts_with("Gmail / Inbox")) + ); + + state.remove_provider(MailProvider::Google); + + assert_eq!( + state + .messages + .iter() + .map(|message| message.input.handle.clone()) + .collect::>(), + microsoft_handles.into_iter().collect::>() + ); + let items = &state.analysis.as_ref().unwrap().items; + assert_eq!(items.len(), items_before); + assert!( + items + .iter() + .all(|item| item.evidence.message != google_handle) + ); + assert!(state.failed_sources.is_empty()); + assert!( + !state + .notices + .iter() + .any(|note| note.starts_with("Gmail / ")) + ); + assert_eq!( + state + .decisions + .records + .iter() + .map(|record| (record.key, record.decision)) + .collect::>(), + decisions_before + .iter() + .map(|record| (record.key, record.decision)) + .collect::>() + ); + } + + #[test] + fn handles_stay_unique_after_a_provider_is_removed() { + // Google mail is loaded first, so it holds the lowest handle indices. + // After removal, `messages.len()` alone would reissue the surviving + // Microsoft message's index to the second appended message. + let mut state = ReviewState::loaded(vec![ + google_source(false, &["g1", "g2"]), + source_review( + "Inbox", + vec![test_mail( + "one", + "c1", + "p1@example.invalid", + vec!["user@example.invalid"], + )], + ), + ]); + state.remove_provider(MailProvider::Google); + state.append_sources(vec![source_review( + "Inbox", + vec![ + test_mail( + "two", + "c2", + "p2@example.invalid", + vec!["user@example.invalid"], + ), + test_mail( + "three", + "c3", + "p3@example.invalid", + vec!["user@example.invalid"], + ), + ], + )]); + assert_eq!(state.messages.len(), 3); + let handles: BTreeSet<_> = state + .messages + .iter() + .map(|message| message.input.handle.clone()) + .collect(); + assert_eq!(handles.len(), state.messages.len()); + } + + #[test] + fn remove_provider_resets_scan_summary_and_errors() { + let mut state = layout_fixture(); + state.append_sources(vec![google_source(false, &["g1"])]); + state.scan_summary = "Synthetic summary that counts Google mail".into(); + state.scan_errors = vec!["Synthetic Google scan error".into()]; + state.scan_incomplete = true; + state.source_failures = 0; + state.failed_conversations_detail.clear(); + + state.remove_provider(MailProvider::Google); + + assert_eq!( + state.scan_summary, + format!( + "{} mail removed from this review. Scan inboxes to refresh the counts.", + MailProvider::Google.service_name() + ) + ); + assert!(state.scan_errors.is_empty()); + assert_eq!( + state.scan_incomplete, + state.source_failures > 0 || !state.failed_conversations_detail.is_empty() + ); + } + #[test] fn retry_count_includes_only_retryable_source_and_conversation_failures() { let mut state = ReviewState::default(); state .failed_sources - .insert("Personal mailbox / Inbox".into()); + .insert((MailProvider::Microsoft, "Personal mailbox / Inbox".into())); state.failed_conversations_detail = vec![ failure("timeout", FailureReason::Timeout), failure("transport", FailureReason::Transport), @@ -2870,7 +3120,7 @@ mod tests { } #[test] - fn google_reminders_are_not_eligible_for_microsoft_sync() { + fn reminder_sync_checks_keep_and_group_provider_provenance() { let mut state = layout_fixture(); let record = state .decisions @@ -2885,7 +3135,42 @@ mod tests { let items = &state.analysis.as_ref().unwrap().items; let cards = state.card_contexts(items); - assert!(state.reminder_sync_checks(items, &cards).is_empty()); + let checks = state.reminder_sync_checks(items, &cards); + assert_eq!(checks.len(), 1); + assert_eq!(checks[0].0, MailProvider::Google); + } + + #[test] + fn reminder_sync_checks_carry_each_provider_when_both_have_records() { + let mut state = layout_fixture(); + let items = state.analysis.as_ref().unwrap().items.clone(); + let cards = state.card_contexts(&items); + let second = cards[1].as_ref().unwrap().record.key; + state.apply_decision_change( + second, + Decision::Mine, + Reminder::Created { + provider: MailProvider::Google, + list_id: "synthetic-list".into(), + task_id: "synthetic-task".into(), + }, + ); + let cards = state.card_contexts(&items); + let checks = state.reminder_sync_checks(&items, &cards); + let mut providers = checks + .iter() + .map(|(provider, _, key, list_id, _)| (*provider, *key, list_id.clone())) + .collect::>(); + providers.sort(); + assert_eq!(providers.len(), 2); + assert!( + providers + .iter() + .any(|(provider, _, _)| *provider == MailProvider::Microsoft) + ); + assert!(providers.iter().any(|(provider, key, list_id)| { + *provider == MailProvider::Google && *key == second && list_id == "synthetic-list" + })); } #[test] @@ -2955,6 +3240,7 @@ mod tests { state.decisions.records = vec![record]; state.draft = Some(ReminderDraft { key, + provider: source.provider, account: source.account.clone(), title: item.action.clone(), when: default_reminder(), @@ -3347,6 +3633,7 @@ mod tests { let mut state = ReviewState { draft: Some(ReminderDraft { key: [7; 32], + provider: MailProvider::Microsoft, account: "acct".into(), title: "Send the draft".into(), when: "2026-09-08 09:00".into(), diff --git a/crates/openloops-desktop/src/slint_review.rs b/crates/openloops-desktop/src/slint_review.rs index 4796363..012b16e 100644 --- a/crates/openloops-desktop/src/slint_review.rs +++ b/crates/openloops-desktop/src/slint_review.rs @@ -22,9 +22,7 @@ use crate::{ sync_list_cached, sync_value_cached, }, }; -use openloops_graph::live::{ - ConnectionConfig, ConnectionError, MailProvider, review::LoadProgress, -}; +use openloops_graph::live::{AccountConfig, ConnectionError, MailProvider, review::LoadProgress}; use openloops_inference::blocks::CanonicalBlock; use slint::{ComponentHandle, Timer}; @@ -73,13 +71,108 @@ fn reminder_title(decision: Decision, action: &str) -> String { } } +fn reminder_request( + draft: &ReminderDraft, + at_utc: i64, +) -> openloops_graph::live::reminders::ReminderRequest { + openloops_graph::live::reminders::ReminderRequest { + provider: draft.provider, + account: draft.account.clone(), + title: draft.title.trim().into(), + at_utc, + marker: marker(&draft.key), + } +} + +fn completion_target( + decision: Decision, + reminder: &Reminder, +) -> Option<(MailProvider, String, String)> { + if decision != Decision::Done { + return None; + } + match reminder { + Reminder::Created { + provider, + list_id, + task_id, + } if !list_id.is_empty() && !task_id.is_empty() => { + Some((*provider, list_id.clone(), task_id.clone())) + } + Reminder::None + | Reminder::Attempted + | Reminder::Created { .. } + | Reminder::Completed { .. } => None, + } +} + 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(); +const MICROSOFT_REMINDER_TIME_NOTE: &str = "This time is your choice, separate from the email deadline. Microsoft To Do controls alert delivery, including when OpenLoops is closed. A failed or uncertain write is never retried automatically."; +const GOOGLE_REMINDER_TIME_NOTE: &str = "Google Tasks saves the date only and does not alert you. The task gets your local calendar date of the time you pick, and that time is written into the task's notes."; + +fn reminder_time_note(provider: MailProvider) -> &'static str { + match provider { + MailProvider::Microsoft => MICROSOFT_REMINDER_TIME_NOTE, + MailProvider::Google => GOOGLE_REMINDER_TIME_NOTE, + } +} + +fn reminder_scope_note(provider: MailProvider) -> &'static str { + match provider { + MailProvider::Microsoft => { + "Creates one task in your personal default Tasks list. Only the title, reminder time, and an opaque OpenLoops reference are sent to Microsoft. This requires delegated Tasks.ReadWrite and a separate browser sign-in." + } + MailProvider::Google => { + "Creates one task in your default Google Tasks list. Only the title, the date, the reminder time as a note, and an opaque OpenLoops reference are sent to Google Tasks. A separate browser sign-in is required." + } + } +} + +/// The short task-service name used on pills and buttons ("To Do" keeps the +/// Microsoft wording unchanged). +const fn tasks_short_name(provider: MailProvider) -> &'static str { + match provider { + MailProvider::Microsoft => "To Do", + MailProvider::Google => "Google Tasks", + } +} + +fn remind_button_label(provider: MailProvider) -> String { + format!("Set {} reminder…", tasks_short_name(provider)) +} + +/// The sync button label for the providers that have reminder records. +fn sync_reminders_label(providers: &std::collections::BTreeSet) -> String { + match providers.iter().collect::>().as_slice() { + [provider] => format!("Sync {}", tasks_short_name(**provider)), + [] => format!("Sync {}", tasks_short_name(MailProvider::Microsoft)), + _ => "Sync reminders".into(), + } +} + +fn sync_checking_status( + eligible: usize, + providers: &std::collections::BTreeSet, +) -> String { + match providers.iter().collect::>().as_slice() { + [provider] => format!( + "Checking {eligible} {} task(s)…", + tasks_short_name(**provider) + ), + _ => format!("Checking {eligible} linked task(s)…"), + } +} + +fn sync_nothing_status(provider: MailProvider) -> String { + format!( + "Nothing to check: no tracked loop has a {} task attached.", + provider.tasks_name() + ) +} #[derive(Clone, Debug, PartialEq, Eq)] struct DraftView { + provider: MailProvider, title: String, when: String, scheduled_line: String, @@ -102,17 +195,10 @@ fn scheduled_instant_line(value: &str) -> (String, bool) { let Ok(at) = reminder_time(value) else { return (REMINDER_VALIDATION_HINT.into(), false); }; - let Some(time) = chrono::DateTime::from_timestamp(at, 0) else { + let Some(time) = openloops_graph::live::reminders::format_local_reminder_time(at) else { return (REMINDER_VALIDATION_HINT.into(), false); }; - ( - format!( - "Scheduled instant: {}", - time.with_timezone(&chrono::Local) - .format("%a %b %d, %Y at %H:%M %:z") - ), - true, - ) + (format!("Scheduled instant: {time}"), true) } fn meeting_time_label(timestamp: i64, approx: bool) -> String { @@ -142,6 +228,7 @@ fn draft_view(draft: Option<&ReminderDraft>) -> Option { let draft = draft?; let (scheduled_line, time_valid) = scheduled_instant_line(&draft.when); Some(DraftView { + provider: draft.provider, title: draft.title.clone(), when: draft.when.clone(), scheduled_line: if draft.error.is_empty() { @@ -179,26 +266,52 @@ struct ReminderView { marker: String, } -fn reminder_state_view(record: &crate::loop_state::Record) -> ReminderView { +/// `card_provider` names the service for `Attempted`, which stores no +/// provider; the other variants use the record's own provider. +fn reminder_state_view( + record: &crate::loop_state::Record, + card_provider: MailProvider, +) -> ReminderView { match &record.reminder { Reminder::None => ReminderView { state: "none", text: "", marker: String::new(), }, - Reminder::Created { .. } => ReminderView { + Reminder::Created { provider, .. } => ReminderView { state: "created", - text: "Reminder created in Microsoft To Do. Marking this loop Handled also marks the task complete there.", + text: match provider { + MailProvider::Microsoft => { + "Reminder created in Microsoft To Do. Marking this loop Handled also marks the task complete there." + } + MailProvider::Google => { + "Reminder created in Google Tasks. Marking this loop Handled also marks the task complete there." + } + }, marker: String::new(), }, - Reminder::Completed { .. } => ReminderView { + Reminder::Completed { provider, .. } => ReminderView { state: "created", - text: "The linked Microsoft To Do task was completed, which marked this loop Handled.", + text: match provider { + MailProvider::Microsoft => { + "The linked Microsoft To Do task was completed, which marked this loop Handled." + } + MailProvider::Google => { + "The linked Google Tasks task was completed, which marked this loop Handled." + } + }, marker: String::new(), }, Reminder::Attempted => ReminderView { state: "attempted", - text: "A reminder attempt has no confirmed outcome. Inspect Microsoft To Do before allowing another attempt.", + text: match card_provider { + MailProvider::Microsoft => { + "A reminder attempt has no confirmed outcome. Inspect Microsoft To Do before allowing another attempt." + } + MailProvider::Google => { + "A reminder attempt has no confirmed outcome. Inspect Google Tasks before allowing another attempt." + } + }, marker: format!("Reference: {}", marker(&record.key)), }, } @@ -594,6 +707,7 @@ struct PillView { #[allow(clippy::struct_excessive_bools)] struct SelectedView { + provider: MailProvider, title: String, meta: Vec<(String, String)>, uncertainty: String, @@ -654,9 +768,10 @@ fn owner_label(item: &LoopItem, decision: Decision) -> &'static str { /// below. fn status_pill_hint(decision: Decision, reminder: &Reminder, item: &LoopItem) -> String { match decision { - Decision::Done if matches!(reminder, Reminder::Completed { .. }) => { - "The linked Microsoft To Do task was completed, which marked this handled.".into() - } + Decision::Done if let Reminder::Completed { provider, .. } = reminder => format!( + "The linked {} task was completed, which marked this handled.", + provider.tasks_name() + ), Decision::Done if item.resolution.is_some() => { let cause = match item.resolution_kind { None => "closing evidence".to_owned(), @@ -750,27 +865,30 @@ fn pills_for(item: &LoopItem, card: &CardContext) -> Vec { hint: "A later message may change this loop. Review the suggestion below.".into(), }); } - match &card.record.reminder { - Reminder::None => {} - Reminder::Created { .. } => pills.push(PillView { - text: "To Do reminder set".into(), - kind: "brand", - hint: String::new(), - }), - Reminder::Completed { .. } => pills.push(PillView { - text: "To Do task completed".into(), - kind: "success", - hint: String::new(), - }), - Reminder::Attempted => pills.push(PillView { - text: "Reminder unconfirmed".into(), - kind: "warning", - hint: String::new(), - }), - } + pills.extend(reminder_pill(&card.record.reminder)); pills } +fn reminder_pill(reminder: &Reminder) -> Option { + let (text, kind) = match reminder { + Reminder::None => return None, + Reminder::Created { provider, .. } => ( + format!("{} reminder set", tasks_short_name(*provider)), + "brand", + ), + Reminder::Completed { provider, .. } => ( + format!("{} task completed", tasks_short_name(*provider)), + "success", + ), + Reminder::Attempted => ("Reminder unconfirmed".into(), "warning"), + }; + Some(PillView { + text, + kind, + hint: String::new(), + }) +} + /// Case-insensitive substring filter over a card's visible text: action /// title, waiting party, subject, evidence quote, deadline text, and status /// pill text -- the same fields shown across the row and the detail pane. An @@ -1023,6 +1141,7 @@ fn selected_view( String::new() }; Some(SelectedView { + provider: source.provider, title: item.action.clone(), meta, uncertainty: item.uncertainty.clone(), @@ -1040,7 +1159,7 @@ fn selected_view( .as_ref() .filter(|draft| draft.key == card.record.key), ), - reminder: reminder_state_view(&card.record), + reminder: reminder_state_view(&card.record, source.provider), source_url, conversation_url, source_unavailable_note, @@ -1307,6 +1426,11 @@ fn sync_review_inner( window.set_show_call_summaries_label(SHOW_CALL_SUMMARY_LABEL.into()); window.set_review_search_query(model.review.search_query.clone().into()); window.set_can_sync_todo(!busy && model.reminder_sync_eligible_count(cards) > 0); + let mut sync_providers = model.reminder_sync_providers(cards); + if sync_providers.is_empty() { + sync_providers.insert(model.primary_mail_provider()); + } + window.set_sync_reminders_label(sync_reminders_label(&sync_providers).into()); strip = apply_scan_failure(strip, &model.review_status, model.review.scan_failed); window.set_scan_strip(strip); window.set_coverage_open(review_ui.coverage_open); @@ -1402,9 +1526,7 @@ 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 @@ -1435,9 +1557,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 && model.active_mail_provider() == MailProvider::Microsoft, - ); + window.set_can_remind(selected.can_remind && !busy); + window.set_remind_button_label(remind_button_label(selected.provider).into()); + window.set_todo_url(selected.provider.tasks_url().into()); let has_links = review_ui.selected.is_some_and(|selected_key| { let loop_link = !model .review @@ -1470,12 +1592,18 @@ fn sync_review_inner( window.set_has_links(has_links); if let Some(draft) = selected.draft { window.set_draft_open(true); + window.set_reminder_service(draft.provider.tasks_name().into()); + window.set_reminder_time_note(reminder_time_note(draft.provider).into()); + window.set_reminder_scope_note(reminder_scope_note(draft.provider).into()); window.set_draft_title(draft.title.into()); window.set_draft_when(draft.when.into()); window.set_draft_scheduled_line(draft.scheduled_line.into()); window.set_draft_valid(draft.valid); } else { window.set_draft_open(false); + window.set_reminder_service(selected.provider.tasks_name().into()); + window.set_reminder_time_note(reminder_time_note(selected.provider).into()); + window.set_reminder_scope_note(reminder_scope_note(selected.provider).into()); window.set_draft_title("".into()); window.set_draft_when("".into()); window.set_draft_scheduled_line("".into()); @@ -1566,7 +1694,13 @@ fn sync_review_inner( window.set_can_track(false); window.set_can_watch(false); window.set_can_remind(false); + let primary = model.primary_mail_provider(); + window.set_todo_url(primary.tasks_url().into()); window.set_draft_open(false); + window.set_reminder_service(primary.tasks_name().into()); + window.set_reminder_time_note(reminder_time_note(primary).into()); + window.set_reminder_scope_note(reminder_scope_note(primary).into()); + window.set_remind_button_label(remind_button_label(primary).into()); window.set_draft_title("".into()); window.set_draft_when("".into()); window.set_draft_scheduled_line("".into()); @@ -1601,12 +1735,6 @@ fn sync_review_inner( 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); @@ -1652,12 +1780,31 @@ fn sign_in_label(provider: MailProvider, mode: ScanMode) -> &'static str { } } +/// The busy label for one mail job: names every provider whose sign-in is +/// still pending; with none pending, the plain download or check text. +fn mail_load_label(pending_sign_ins: &[MailProvider], mode: ScanMode) -> &'static str { + match (pending_sign_ins, mode) { + ([], ScanMode::Full) => "Downloading recent messages", + ([], ScanMode::Incremental) => "Checking for new mail", + ([provider], mode) => sign_in_label(*provider, mode), + (_, ScanMode::Full) => { + "Complete Microsoft and Google sign-in; then downloading recent messages" + } + (_, ScanMode::Incremental) => { + "Complete Microsoft and Google sign-in; then checking for new mail" + } + } +} + +fn pending_sign_ins(providers: impl IntoIterator) -> Vec { + providers + .into_iter() + .filter(|provider| !openloops_graph::live::has_session_for(*provider)) + .collect() +} + fn start_mail_load(model: &Rc>, mode: ScanMode) -> Result<(), ConnectionError> { - let (provider, config) = { - let model = model.borrow(); - let provider = model.active_mail_provider(); - (provider, model.account_config(provider)?) - }; + let accounts = model.borrow_mut().enabled_accounts()?; let mut model_ref = model.borrow_mut(); if mode == ScanMode::Full { let decisions = std::mem::take(&mut model_ref.review.decisions); @@ -1673,27 +1820,26 @@ 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(provider); + let label = mail_load_label( + &pending_sign_ins(accounts.iter().map(AccountConfig::provider)), + mode, + ); match mode { ScanMode::Full => model_ref.start( Service::Review, - if signed_in { - "Downloading recent messages" - } else { - sign_in_label(provider, ScanMode::Full) + label, + move || { + Outcome::Mail(openloops_graph::live::load_all( + &accounts, &cache, &progress, + )) }, - move || Outcome::Mail(config.load_recent_with(&cache, &progress)), || {}, ), ScanMode::Incremental => model_ref.start( Service::Review, - if signed_in { - "Checking for new mail" - } else { - sign_in_label(provider, ScanMode::Incremental) - }, + label, move || Outcome::CheckMail { - sources: config.load_recent_with(&cache, &progress), + loads: openloops_graph::live::load_all(&accounts, &cache, &progress), }, || {}, ), @@ -1730,7 +1876,12 @@ pub(crate) fn sync_review( /// "Recorded: ..." wording is layered on top of it (S8). Factored out of /// `on_reconcile_reminder` so both outcomes are directly testable without a /// live `AppWindow`. -fn apply_reconcile(review: &mut ReviewState, key: [u8; 32], exists: bool) { +fn apply_reconcile( + review: &mut ReviewState, + key: [u8; 32], + fallback_provider: MailProvider, + exists: bool, +) { let record = review.decisions.get(&key); let reminder = if exists { // The user is manually confirming a task exists after an Uncertain @@ -1742,7 +1893,7 @@ fn apply_reconcile(review: &mut ReviewState, key: [u8; 32], exists: bool) { // purpose (dedup, the pill, the button state). let provider = match record.reminder { Reminder::Created { provider, .. } | Reminder::Completed { provider, .. } => provider, - Reminder::None | Reminder::Attempted => MailProvider::Microsoft, + Reminder::None | Reminder::Attempted => fallback_provider, }; Reminder::Created { provider, @@ -1759,19 +1910,22 @@ fn dispatch_pending_reminder(model: &mut AppModel) -> bool { let Some((key, request)) = model.review.pending_reminder.take() else { return false; }; - match model - .effective_microsoft_client_id() - .ok_or(ConnectionError::InvalidConfiguration) - .and_then(|client_id| ConnectionConfig::new(&client_id, None)) - { + let provider = request.provider; + match model.reminder_account_config(provider) { Ok(config) => { model.start( Service::Review, - "Sign in to create the reviewed Microsoft To Do reminder", + match provider { + MailProvider::Microsoft => { + "Sign in to create the reviewed Microsoft To Do reminder" + } + MailProvider::Google => "Sign in to create the reviewed Google Tasks reminder", + }, move || { Outcome::Reminder( key, - openloops_graph::live::reminders::create(&config, &request), + provider, + openloops_graph::live::provider::create_reminder(&config, &request), ) }, || {}, @@ -1831,32 +1985,21 @@ fn decide_selected(model: &mut AppModel, selected: Option<[u8; 32]>, value: i32) // the decision or the reminder record on failure (see // `reminders::complete()`), so it only ever affects the status // line, dispatched after the decision is already saved. - let complete_task = if decision == Decision::Done { - match &reminder { - Reminder::Created { - provider: MailProvider::Microsoft, - list_id, - task_id, - } if !list_id.is_empty() && !task_id.is_empty() => { - Some((list_id.clone(), task_id.clone())) - } - _ => None, - } - } else { - None - }; + let complete_task = completion_target(decision, &reminder); model.review.apply_decision_change(key, decision, reminder); - if let Some((list_id, task_id)) = complete_task - && let Some(client_id) = model.effective_microsoft_client_id() - && let Ok(config) = ConnectionConfig::new(&client_id, None) + if let Some((provider, list_id, task_id)) = complete_task + && let Ok(config) = model.reminder_account_config(provider) { model.start( Service::Review, - "Marking the linked Microsoft To Do task complete", + match provider { + MailProvider::Microsoft => "Marking the linked Microsoft To Do task complete", + MailProvider::Google => "Marking the linked Google Tasks task complete", + }, move || { Outcome::ReminderCompletion( key, - openloops_graph::live::reminders::complete( + openloops_graph::live::provider::complete_reminder( &config, &account, &list_id, &task_id, ), ) @@ -1961,10 +2104,6 @@ pub(crate) fn register_callbacks( model: &Rc>, timer: &Rc, ) { - // Set once here rather than in every `sync`: the To Do URL never - // changes, and `review.slint` never embeds the literal itself (it calls - // back through `open-external(root.todo-url)`). - window.set_todo_url(TODO_URL.into()); let model = Rc::clone(model); let timer = Rc::clone(timer); { @@ -2014,29 +2153,29 @@ pub(crate) fn register_callbacks( .borrow_mut() .start_retry_scan(conversations, attempted, || {}); } else { - let (provider, config) = { - let model = model.borrow(); - let provider = model.active_mail_provider(); - (provider, model.account_config(provider)) - }; - match config { - Ok(config) => { + let jobs = model.borrow_mut().retry_jobs(&sources); + match jobs { + Ok(jobs) => { + // A skipped provider keeps its failed sources. + let sources: std::collections::BTreeSet<_> = sources + .into_iter() + .filter(|(provider, _)| { + jobs.iter() + .any(|(account, _)| account.provider() == *provider) + }) + .collect(); let mut model_ref = model.borrow_mut(); 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 pending = + pending_sign_ins(jobs.iter().map(|(account, _)| account.provider())); model_ref.start( Service::Review, - if openloops_graph::live::has_session_for(provider) { - "Downloading recent messages" - } else { - sign_in_label(provider, ScanMode::Full) - }, + mail_load_label(&pending, ScanMode::Full), move || Outcome::RetryMail { - sources: config.load_sources_with( - &cache, - &progress, - Some(&sources), + loads: openloops_graph::live::load_selected( + &jobs, &cache, &progress, ), source_keys: sources, conversations, @@ -2136,10 +2275,11 @@ pub(crate) fn register_callbacks( let eligible = model_ref.reminder_sync_eligible_count(&cards); if eligible == 0 { model_ref.review.action_status = - "Nothing to check: no tracked loop has a Microsoft To Do task attached.".into(); + sync_nothing_status(model_ref.primary_mail_provider()); model_ref.review.action_status_succeeded = false; } else { - model_ref.review.action_status = format!("Checking {eligible} To Do task(s)…"); + let providers = model_ref.reminder_sync_providers(&cards); + model_ref.review.action_status = sync_checking_status(eligible, &providers); model_ref.review.action_status_succeeded = true; model_ref.dispatch_reminder_sync(); } @@ -2374,6 +2514,7 @@ pub(crate) fn register_callbacks( return; }; let account = source.account.clone(); + let provider = source.provider; let source_id = source.id.clone(); let evidence_block = item.evidence.block; let evidence_quote = item.evidence.quote.clone(); @@ -2398,6 +2539,7 @@ pub(crate) fn register_callbacks( .apply_decision_change(key, next, record.reminder); model_ref.review.draft = Some(ReminderDraft { key, + provider, account, title, when: default_reminder(), @@ -2494,13 +2636,6 @@ 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; }; @@ -2508,16 +2643,8 @@ pub(crate) fn register_callbacks( Ok(at) if title_is_valid(&draft.title) => { match model_ref.review.decisions.begin_reminder(draft.key) { Ok(()) => { - model_ref.review.pending_reminder = Some(( - draft.key, - openloops_graph::live::reminders::ReminderRequest { - provider: openloops_graph::live::MailProvider::Microsoft, - account: draft.account, - title: draft.title.trim().into(), - at_utc: at, - marker: marker(&draft.key), - }, - )); + model_ref.review.pending_reminder = + Some((draft.key, reminder_request(&draft, at))); if dispatch_pending_reminder(&mut model_ref) { start_timer(&timer); } @@ -2545,7 +2672,21 @@ pub(crate) fn register_callbacks( let selected = REVIEW_UI.with(|state| state.borrow().selected); let Some(key) = selected else { return }; let mut model_ref = model.borrow_mut(); - apply_reconcile(&mut model_ref.review, key, exists); + let provider = model_ref + .review + .analysis + .as_ref() + .and_then(|analysis| { + let cards = model_ref.review.card_contexts(&analysis.items); + cards + .iter() + .position(|card| card.as_ref().is_some_and(|card| card.record.key == key)) + .and_then(|index| analysis.items.get(index)) + .and_then(|item| source_message(&model_ref.review, item)) + .map(|source| source.provider) + }) + .unwrap_or_else(|| model_ref.primary_mail_provider()); + apply_reconcile(&mut model_ref.review, key, provider, exists); drop(model_ref); refresh(&model, &weak); }); @@ -2979,7 +3120,14 @@ mod tests { 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_heading_pills() + .iter() + .map(|pill| pill.to_string()) + .collect::>(), + ["Personal inbox confirmed"] + ); assert_eq!(window.get_microsoft_lines().row_count(), 0); assert_eq!(window.get_google_lines().row_count(), 1); @@ -3019,15 +3167,135 @@ mod tests { 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.enabled_mail_providers(), + [MailProvider::Microsoft] + ); + assert!(callback_model.review.analysis.is_some()); + assert_eq!( + callback_model.review.action_status, + "Synthetic action status" + ); assert_eq!(callback_model.client_id, "malformed-guid"); assert_eq!( callback_model.google_client_id, "123-synthetic.apps.googleusercontent.com" ); drop(callback_model); + assert!(!window.get_provider_enabled()); + + // "Include in scans" on the Google view enables Google as well. + window.invoke_provider_enabled_toggled(true); + assert_eq!( + mailbox_callback_model.borrow().enabled_mail_providers(), + [MailProvider::Microsoft, MailProvider::Google] + ); + assert!(window.get_provider_enabled()); + assert_eq!( + window.get_scan_providers_text().as_str(), + "Scans include: Microsoft 365, Google" + ); + assert_eq!( + window.get_scope_folders_label().as_str(), + "Inbox / Sent (both providers)" + ); + // The fixture's messages are Microsoft mail, so only Microsoft is connected. + assert_eq!( + window + .get_heading_pills() + .iter() + .map(|pill| pill.to_string()) + .collect::>(), + ["Microsoft connected"] + ); + mailbox_callback_model.borrow_mut().google.succeeded = true; + crate::slint_ui::sync(&mailbox_callback_model.borrow(), &window); + assert_eq!(window.get_heading_pills().row_count(), 2); + assert_eq!( + window.get_account_text().as_str(), + "Signed in · Microsoft 365 + Google" + ); + + // Disabling Google leaves Microsoft; disabling the last one is refused. + window.invoke_provider_enabled_toggled(false); + assert_eq!( + mailbox_callback_model.borrow().enabled_mail_providers(), + [MailProvider::Microsoft] + ); + window.invoke_mail_provider_selected(0); + window.set_provider_enabled(false); + window.invoke_provider_enabled_toggled(false); + assert_eq!( + mailbox_callback_model.borrow().enabled_mail_providers(), + [MailProvider::Microsoft] + ); + assert!(window.get_provider_enabled()); + assert_eq!( + window.get_settings_status().as_str(), + "Keep at least one mail provider enabled." + ); + + // Viewing the Google segment with only Microsoft enabled changes the + // Sources card only: the Review pane copy, the "nothing to check" + // status and the reconcile fallback stay Microsoft. + let primary_model = Rc::new(RefCell::new(AppModel::with_store(Ok(None)))); + primary_model + .borrow_mut() + .set_active_mail_provider(MailProvider::Google); + assert_eq!( + primary_model.borrow().enabled_mail_providers(), + [MailProvider::Microsoft] + ); + REVIEW_UI.with(|state| state.borrow_mut().selected = None); + sync_review( + &primary_model.borrow(), + &window, + &[], + false, + false, + ScanStripModel::default(), + ); + assert_eq!( + window.get_todo_url().as_str(), + MailProvider::Microsoft.tasks_url() + ); + assert_eq!(window.get_reminder_service().as_str(), "Microsoft To Do"); + assert_eq!( + window.get_reminder_time_note().as_str(), + MICROSOFT_REMINDER_TIME_NOTE + ); + assert_eq!( + window.get_reminder_scope_note().as_str(), + reminder_scope_note(MailProvider::Microsoft) + ); + assert_eq!( + window.get_remind_button_label().as_str(), + "Set To Do reminder…" + ); + assert_eq!(window.get_sync_reminders_label().as_str(), "Sync To Do"); + let primary_timer = Rc::new(Timer::default()); + register_callbacks(&window, &primary_model, &primary_timer); + window.invoke_sync_todo(); + assert_eq!( + primary_model.borrow().review.action_status, + sync_nothing_status(MailProvider::Microsoft) + ); + let orphan_key = [0x5a; 32]; + REVIEW_UI.with(|state| state.borrow_mut().selected = Some(orphan_key)); + window.invoke_reconcile_reminder(true); + assert!(matches!( + primary_model + .borrow() + .review + .decisions + .get(&orphan_key) + .reminder, + Reminder::Created { + provider: MailProvider::Microsoft, + .. + } + )); + REVIEW_UI.with(|state| state.borrow_mut().selected = None); model.mail_providers = std::collections::BTreeSet::from([MailProvider::Microsoft]); model.provider = crate::settings::Provider::OpenRouter; @@ -3096,10 +3364,24 @@ mod tests { false, ScanStripModel::default(), ); - assert!(!window.get_can_remind()); + assert!(window.get_can_remind()); + assert_eq!(window.get_reminder_service().as_str(), "Microsoft To Do"); assert_eq!( - window.get_review_action_status().as_str(), - GOOGLE_REMINDER_UNAVAILABLE + window.get_reminder_time_note().as_str(), + MICROSOFT_REMINDER_TIME_NOTE + ); + assert_eq!( + window.get_reminder_scope_note().as_str(), + reminder_scope_note(MailProvider::Microsoft) + ); + assert_eq!( + window.get_remind_button_label().as_str(), + "Set To Do reminder…" + ); + // No reminder records here: the label follows the active provider. + assert_eq!( + window.get_sync_reminders_label().as_str(), + "Sync Google Tasks" ); model.review.decisions.error = Some("Synthetic decision error".into()); sync_review( @@ -3127,6 +3409,7 @@ mod tests { ); assert!(!window.get_can_track()); assert!(!window.get_can_watch()); + assert_eq!(window.get_sync_reminders_label().as_str(), "Sync To Do"); // Requirement 6b (perf fix, owner round 3): a `sync_review` re-run // over unchanged content must leave every projected list model in @@ -3272,14 +3555,6 @@ 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)); @@ -3342,6 +3617,35 @@ mod tests { std::fs::remove_dir_all(&export_folder).ok(); } + #[test] + fn mail_load_label_lists_every_pending_sign_in() { + use MailProvider::{Google, Microsoft}; + assert_eq!( + mail_load_label(&[], ScanMode::Full), + "Downloading recent messages" + ); + assert_eq!( + mail_load_label(&[], ScanMode::Incremental), + "Checking for new mail" + ); + assert_eq!( + mail_load_label(&[Microsoft], ScanMode::Full), + "Complete Microsoft sign-in; then downloading recent messages" + ); + assert_eq!( + mail_load_label(&[Google], ScanMode::Incremental), + "Complete Google sign-in; then checking for new mail" + ); + assert_eq!( + mail_load_label(&[Microsoft, Google], ScanMode::Full), + "Complete Microsoft and Google sign-in; then downloading recent messages" + ); + assert_eq!( + mail_load_label(&[Microsoft, Google], ScanMode::Incremental), + "Complete Microsoft and Google sign-in; then checking for new mail" + ); + } + #[test] fn owner_and_source_labels_cover_all_branches() { let mut review = crate::review_model::layout_fixture(); @@ -3887,6 +4191,7 @@ mod tests { ); let draft = ReminderDraft { key: [1; 32], + provider: MailProvider::Microsoft, account: "synthetic".into(), title: "Send notes".into(), when: valid_value, @@ -3896,6 +4201,49 @@ mod tests { assert!(draft_view(Some(&draft)).unwrap().valid); } + #[test] + fn reminder_service_and_time_note_copy_are_provider_specific() { + assert_eq!(MailProvider::Microsoft.tasks_name(), "Microsoft To Do"); + assert_eq!( + reminder_time_note(MailProvider::Microsoft), + MICROSOFT_REMINDER_TIME_NOTE + ); + assert_eq!(MailProvider::Google.tasks_name(), "Google Tasks"); + assert_eq!( + reminder_time_note(MailProvider::Google), + "Google Tasks saves the date only and does not alert you. The task gets your local calendar date of the time you pick, and that time is written into the task's notes." + ); + } + + #[test] + fn reminder_create_and_completion_targets_preserve_provider() { + for provider in [MailProvider::Microsoft, MailProvider::Google] { + let draft = ReminderDraft { + key: [4; 32], + provider, + account: "synthetic-account".into(), + title: " Review the draft ".into(), + when: "2096-10-02 07:06".into(), + error: String::new(), + prior_decision: Decision::Mine, + }; + let request = reminder_request(&draft, 4_000_000_000); + assert_eq!(request.provider, provider); + assert_eq!(request.title, "Review the draft"); + + let reminder = Reminder::Created { + provider, + list_id: "synthetic-list".into(), + task_id: "synthetic-task".into(), + }; + assert_eq!( + completion_target(Decision::Done, &reminder), + Some((provider, "synthetic-list".into(), "synthetic-task".into())) + ); + assert!(completion_target(Decision::Mine, &reminder).is_none()); + } + } + #[test] fn reminder_state_view_covers_every_variant() { let mut record = Record { @@ -3904,13 +4252,16 @@ mod tests { reminder: Reminder::None, updated: 0, }; - assert_eq!(reminder_state_view(&record).state, "none"); + assert_eq!( + reminder_state_view(&record, MailProvider::Microsoft).state, + "none" + ); record.reminder = Reminder::Created { provider: openloops_graph::live::MailProvider::Microsoft, list_id: "list".into(), task_id: "task".into(), }; - let created = reminder_state_view(&record); + let created = reminder_state_view(&record, MailProvider::Microsoft); assert_eq!(created.state, "created"); assert!( created @@ -3918,11 +4269,156 @@ mod tests { .starts_with("Reminder created in Microsoft To Do.") ); record.reminder = Reminder::Attempted; - let attempted = reminder_state_view(&record); + let attempted = reminder_state_view(&record, MailProvider::Microsoft); assert_eq!(attempted.state, "attempted"); assert!(attempted.marker.starts_with("Reference: ")); } + #[test] + fn reminder_state_view_text_follows_the_provider() { + let mut record = Record { + key: [0xab; 32], + decision: Decision::Done, + reminder: Reminder::Completed { + provider: MailProvider::Microsoft, + list_id: "list".into(), + task_id: "task".into(), + }, + updated: 0, + }; + // The record's own provider wins over the card's for Completed. + assert_eq!( + reminder_state_view(&record, MailProvider::Google).text, + "The linked Microsoft To Do task was completed, which marked this loop Handled." + ); + record.reminder = Reminder::Completed { + provider: MailProvider::Google, + list_id: "list".into(), + task_id: "task".into(), + }; + assert_eq!( + reminder_state_view(&record, MailProvider::Microsoft).text, + "The linked Google Tasks task was completed, which marked this loop Handled." + ); + // Attempted stores no provider: the card's provider names the service. + record.reminder = Reminder::Attempted; + assert_eq!( + reminder_state_view(&record, MailProvider::Microsoft).text, + "A reminder attempt has no confirmed outcome. Inspect Microsoft To Do before allowing another attempt." + ); + assert_eq!( + reminder_state_view(&record, MailProvider::Google).text, + "A reminder attempt has no confirmed outcome. Inspect Google Tasks before allowing another attempt." + ); + } + + #[test] + fn reminder_pills_and_completed_hint_follow_the_provider() { + let completed = |provider| Reminder::Completed { + provider, + list_id: "list".into(), + task_id: "task".into(), + }; + let created = |provider| Reminder::Created { + provider, + list_id: "list".into(), + task_id: "task".into(), + }; + let text = |reminder: &Reminder| reminder_pill(reminder).map(|pill| pill.text); + assert_eq!( + text(&created(MailProvider::Microsoft)).as_deref(), + Some("To Do reminder set") + ); + assert_eq!( + text(&completed(MailProvider::Microsoft)).as_deref(), + Some("To Do task completed") + ); + assert_eq!( + text(&created(MailProvider::Google)).as_deref(), + Some("Google Tasks reminder set") + ); + assert_eq!( + text(&completed(MailProvider::Google)).as_deref(), + Some("Google Tasks task completed") + ); + assert_eq!( + text(&Reminder::Attempted).as_deref(), + Some("Reminder unconfirmed") + ); + assert!(reminder_pill(&Reminder::None).is_none()); + + let review = crate::review_model::layout_fixture(); + let item = review.analysis.as_ref().unwrap().items[0].clone(); + assert_eq!( + status_pill_hint(Decision::Done, &completed(MailProvider::Google), &item), + "The linked Google Tasks task was completed, which marked this handled." + ); + } + + #[test] + fn reminder_panel_copy_is_per_provider() { + assert_eq!( + reminder_scope_note(MailProvider::Microsoft), + "Creates one task in your personal default Tasks list. Only the title, reminder time, and an opaque OpenLoops reference are sent to Microsoft. This requires delegated Tasks.ReadWrite and a separate browser sign-in." + ); + assert_eq!( + reminder_scope_note(MailProvider::Google), + "Creates one task in your default Google Tasks list. Only the title, the date, the reminder time as a note, and an opaque OpenLoops reference are sent to Google Tasks. A separate browser sign-in is required." + ); + assert_eq!( + remind_button_label(MailProvider::Microsoft), + "Set To Do reminder…" + ); + assert_eq!( + remind_button_label(MailProvider::Google), + "Set Google Tasks reminder…" + ); + } + + #[test] + fn sync_button_and_status_copy_follow_the_providers_with_records() { + use std::collections::BTreeSet; + let microsoft = BTreeSet::from([MailProvider::Microsoft]); + let google = BTreeSet::from([MailProvider::Google]); + let both = BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + assert_eq!(sync_reminders_label(µsoft), "Sync To Do"); + assert_eq!(sync_reminders_label(&google), "Sync Google Tasks"); + assert_eq!(sync_reminders_label(&both), "Sync reminders"); + assert_eq!( + sync_checking_status(2, µsoft), + "Checking 2 To Do task(s)…" + ); + assert_eq!( + sync_checking_status(2, &google), + "Checking 2 Google Tasks task(s)…" + ); + assert_eq!(sync_checking_status(3, &both), "Checking 3 linked task(s)…"); + assert_eq!( + sync_nothing_status(MailProvider::Microsoft), + "Nothing to check: no tracked loop has a Microsoft To Do task attached." + ); + assert_eq!( + sync_nothing_status(MailProvider::Google), + "Nothing to check: no tracked loop has a Google Tasks task attached." + ); + } + + #[test] + fn scheduled_line_uses_the_shared_local_time_formatter() { + let when = reminder_quick_pick_in_hour(chrono::Local::now()); + let at = reminder_time(&when).unwrap(); + assert_eq!( + scheduled_instant_line(&when), + ( + format!( + "Scheduled instant: {}", + openloops_graph::live::reminders::format_local_reminder_time(at).unwrap() + ), + true + ) + ); + } + #[test] fn evidence_cards_cover_request_deadline_event_and_subject_time() { let review = crate::review_model::layout_fixture(); @@ -4055,6 +4551,7 @@ mod tests { let valid_when = reminder_quick_pick_in_hour(chrono::Local::now()); let with_error = ReminderDraft { key: [1; 32], + provider: MailProvider::Microsoft, account: "synthetic".into(), title: "Send notes".into(), when: valid_when.clone(), @@ -4073,6 +4570,7 @@ mod tests { let short_title = ReminderDraft { key: [1; 32], + provider: MailProvider::Microsoft, account: "synthetic".into(), title: "ab".into(), when: valid_when.clone(), @@ -4083,6 +4581,7 @@ mod tests { let long_title = ReminderDraft { key: [1; 32], + provider: MailProvider::Microsoft, account: "synthetic".into(), title: "x".repeat(321), when: valid_when, @@ -4105,7 +4604,7 @@ mod tests { "send the draft budget", ); - apply_reconcile(&mut review, key, true); + apply_reconcile(&mut review, key, MailProvider::Microsoft, true); assert!(review.action_status_succeeded); assert_eq!( review.action_status, @@ -4120,7 +4619,7 @@ mod tests { review.decisions.error = Some( "The saved-decision limit (55) is reached. Existing decisions are preserved.".into(), ); - apply_reconcile(&mut review, key, false); + apply_reconcile(&mut review, key, MailProvider::Microsoft, false); assert!(!review.action_status_succeeded); assert_eq!( review.action_status, @@ -4152,7 +4651,7 @@ mod tests { }; *provider = MailProvider::Google; - apply_reconcile(&mut review, key, true); + apply_reconcile(&mut review, key, MailProvider::Google, true); assert!(matches!( review.decisions.get(&key).reminder, diff --git a/crates/openloops-desktop/src/slint_ui.rs b/crates/openloops-desktop/src/slint_ui.rs index 1d2f762..f078c1c 100644 --- a/crates/openloops-desktop/src/slint_ui.rs +++ b/crates/openloops-desktop/src/slint_ui.rs @@ -157,6 +157,80 @@ fn replace_if_changed(current: &mut T, next: T) -> bool { } } +/// Providers shown as connected: the connection check succeeded, or review +/// messages from that provider are already loaded. +fn connected_providers(model: &AppModel) -> Vec { + MailProvider::ALL + .iter() + .copied() + .filter(|provider| { + model.connection_status(*provider).succeeded + || model + .review + .messages + .iter() + .any(|message| message.provider == *provider) + }) + .collect() +} + +/// The Sources card's provider-dependent text, computed outside Slint. +#[derive(Debug, PartialEq, Eq)] +struct SourcesView { + heading_pills: Vec, + provider_enabled: bool, + scan_providers_text: String, + scope_folders_label: &'static str, + scope_groups_text: &'static str, +} + +fn sources_view(model: &AppModel, connected: &[MailProvider]) -> SourcesView { + let enabled = model.enabled_mail_providers(); + let both = enabled.len() > 1; + let heading_pills = if both { + connected + .iter() + .map(|provider| match provider { + MailProvider::Microsoft => "Microsoft connected".to_owned(), + MailProvider::Google => "Google connected".to_owned(), + }) + .collect() + } else if enabled.first().is_some_and(|provider| { + model + .connection_status(*provider) + .lines + .first() + .is_some_and(|line| line == "Personal inbox: access confirmed.") + }) { + vec!["Personal inbox confirmed".to_owned()] + } else { + Vec::new() + }; + let (scope_folders_label, scope_groups_text) = match enabled.as_slice() { + [MailProvider::Google] => ("Inbox / Sent", "Microsoft only"), + [_, _, ..] => ("Inbox / Sent (both providers)", "Microsoft only"), + _ => ("Inbox / Sent Items", "20 recent threads · 40 posts each"), + }; + SourcesView { + heading_pills, + provider_enabled: enabled.contains(&model.active_mail_provider()), + scan_providers_text: if both { + format!( + "Scans include: {}", + enabled + .iter() + .map(|provider| provider.service_name()) + .collect::>() + .join(", ") + ) + } else { + String::new() + }, + scope_folders_label, + scope_groups_text, + } +} + fn provider_connected(model: &AppModel) -> bool { !model.selected_model().is_empty() && model.model_status.succeeded } @@ -220,19 +294,9 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { // succeeded, or once review messages have already loaded (mail is // plainly being scanned even if a rescan's own check has not yet // re-run), rather than a misleading "Not signed in". - let connected_providers = MailProvider::ALL - .iter() - .copied() - .filter(|provider| { - model.connection_status(*provider).succeeded - || model - .review - .messages - .iter() - .any(|message| message.provider == *provider) - }) - .collect::>(); + let connected_providers = connected_providers(model); let account = AccountDisplay::connected(&connected_providers); + let sources = sources_view(model, &connected_providers); let cards = model .review .analysis @@ -289,13 +353,6 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { window.set_client_id(model.client_id.clone().into()); window.set_groups(model.groups.clone().into()); window.set_shared_mailboxes(model.shared.clone().into()); - window.set_own_inbox_accessible( - model - .connection_status(model.active_mail_provider()) - .lines - .first() - .is_some_and(|line| line == "Personal inbox: access confirmed."), - ); let microsoft_lines = model .microsoft .lines @@ -354,6 +411,17 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { MailProvider::Microsoft => 0, MailProvider::Google => 1, }); + window.set_heading_pills(ModelRc::new(VecModel::from( + sources + .heading_pills + .into_iter() + .map(SharedString::from) + .collect::>(), + ))); + window.set_provider_enabled(sources.provider_enabled); + window.set_scan_providers_text(sources.scan_providers_text.into()); + window.set_scope_folders_label(sources.scope_folders_label.into()); + window.set_scope_groups_text(sources.scope_groups_text.into()); 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() @@ -562,8 +630,11 @@ pub(crate) fn register_check_mailbox_callback( }); } -pub(crate) fn register_mail_provider_callback(window: &AppWindow, model: &Rc>) { - let model = Rc::clone(model); +pub(crate) fn register_mail_provider_callback( + window: &AppWindow, + shared_model: &Rc>, +) { + let model = Rc::clone(shared_model); let weak = window.as_weak(); window.on_mail_provider_selected(move |index| { let provider = if index == 1 { @@ -577,6 +648,15 @@ pub(crate) fn register_mail_provider_callback(window: &AppWindow, model: &Rc>, weak: &slint::Weak) { @@ -1374,6 +1454,57 @@ mod tests { AppModel::with_store(Ok(None)) } + #[test] + fn sources_view_with_one_provider_keeps_the_single_provider_copy() { + let mut model = model(); + let view = sources_view(&model, &[]); + assert!(view.heading_pills.is_empty()); + assert!(view.provider_enabled); + assert!(view.scan_providers_text.is_empty()); + assert_eq!(view.scope_folders_label, "Inbox / Sent Items"); + assert_eq!(view.scope_groups_text, "20 recent threads · 40 posts each"); + + model.microsoft.lines = vec!["Personal inbox: access confirmed.".into()]; + let view = sources_view(&model, &[MailProvider::Microsoft]); + assert_eq!(view.heading_pills, ["Personal inbox confirmed"]); + + model.set_active_mail_provider(MailProvider::Google); + let view = sources_view(&model, &[MailProvider::Microsoft]); + assert!(!view.provider_enabled); + assert_eq!(view.scope_folders_label, "Inbox / Sent Items"); + + model.mail_providers = std::collections::BTreeSet::from([MailProvider::Google]); + let view = sources_view(&model, &[]); + assert!(view.provider_enabled); + assert_eq!(view.scope_folders_label, "Inbox / Sent"); + assert_eq!(view.scope_groups_text, "Microsoft only"); + } + + #[test] + fn sources_view_with_both_providers_shows_one_pill_per_connected_provider() { + let mut model = model(); + model.mail_providers = + std::collections::BTreeSet::from([MailProvider::Microsoft, MailProvider::Google]); + model.microsoft.lines = vec!["Personal inbox: access confirmed.".into()]; + let view = sources_view(&model, &[MailProvider::Microsoft]); + assert_eq!(view.heading_pills, ["Microsoft connected"]); + let view = sources_view(&model, &[MailProvider::Microsoft, MailProvider::Google]); + assert_eq!( + view.heading_pills, + ["Microsoft connected", "Google connected"] + ); + assert_eq!( + view.scan_providers_text, + "Scans include: Microsoft 365, Google" + ); + assert_eq!(view.scope_folders_label, "Inbox / Sent (both providers)"); + assert_eq!(view.scope_groups_text, "Microsoft only"); + assert_eq!( + AccountDisplay::connected(&[MailProvider::Microsoft, MailProvider::Google]).name, + "Signed in · Microsoft 365 + Google" + ); + } + #[test] fn unchanged_edit_guard_does_not_request_a_refresh() { let mut value = String::from("unchanged"); diff --git a/crates/openloops-desktop/ui/app.slint b/crates/openloops-desktop/ui/app.slint index 9fd8e5d..bcdf2b0 100644 --- a/crates/openloops-desktop/ui/app.slint +++ b/crates/openloops-desktop/ui/app.slint @@ -42,10 +42,14 @@ export component AppWindow inherits Window { in-out property client-id; in-out property groups; in-out property shared-mailboxes; - in property own-inbox-accessible; in property <[StatusLine]> microsoft-lines; in property microsoft-busy-line; in-out property mail-provider-index; + in property <[string]> heading-pills; + in-out property provider-enabled; + in property scan-providers-text; + in property scope-folders-label: "Inbox / Sent Items"; + in property scope-groups-text: "20 recent threads · 40 posts each"; in property shipped-google-registration-present; in-out property google-client-id; in-out property google-client-secret; @@ -119,6 +123,11 @@ export component AppWindow inherits Window { in-out property draft-when; in property draft-scheduled-line; in property draft-valid; + in property reminder-service; + in property reminder-time-note; + in property reminder-scope-note; + in property remind-button-label: "Set To Do reminder…"; + in property sync-reminders-label: "Sync To Do"; in property reminder-state; in property source-url; in property conversation-url; @@ -139,6 +148,7 @@ export component AppWindow inherits Window { callback groups-edited(string); callback shared-edited(string); callback mail-provider-selected(int); + callback provider-enabled-toggled(bool); callback google-client-id-edited(string); callback google-client-secret-edited(string); callback open-google-console; @@ -196,9 +206,11 @@ export component AppWindow inherits Window { if root.active-screen == 1: SourcesScreen { controls-enabled: !root.busy; store-available: root.store-available; settings-status: root.settings-status; settings-succeeded: root.settings-succeeded; 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; + client-id <=> root.client-id; groups <=> root.groups; shared-mailboxes <=> root.shared-mailboxes; 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; + heading-pills: root.heading-pills; provider-enabled <=> root.provider-enabled; scan-providers-text: root.scan-providers-text; + scope-folders-label: root.scope-folders-label; scope-groups-text: root.scope-groups-text; 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; @@ -212,7 +224,7 @@ export component AppWindow inherits Window { 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); } - mail-provider-selected(index) => { root.mail-provider-selected(index); } + mail-provider-selected(index) => { root.mail-provider-selected(index); } provider-enabled-toggled(value) => { root.provider-enabled-toggled(value); } 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); } @@ -232,7 +244,7 @@ export component AppWindow inherits Window { pills: root.review-pills; title: root.review-title; meta: root.review-meta; uncertainty: root.review-uncertainty; action-status: root.review-action-status; action-status-succeeded: root.review-action-status-succeeded; linking-status: root.linking-status; has-links: root.has-links; selected-open: root.selected-open; selected-terminal: root.selected-terminal; can-track: root.can-track; can-watch: root.can-watch; can-remind: root.can-remind; - draft-open: root.draft-open; draft-title <=> root.draft-title; draft-when <=> root.draft-when; draft-scheduled-line: root.draft-scheduled-line; draft-valid: root.draft-valid; + draft-open: root.draft-open; draft-title <=> root.draft-title; draft-when <=> root.draft-when; draft-scheduled-line: root.draft-scheduled-line; draft-valid: root.draft-valid; reminder-service: root.reminder-service; reminder-time-note: root.reminder-time-note; reminder-scope-note: root.reminder-scope-note; remind-button-label: root.remind-button-label; sync-reminders-label: root.sync-reminders-label; reminder-state: root.reminder-state; source-url: root.source-url; conversation-url: root.conversation-url; source-unavailable-note: root.source-unavailable-note; evidence-cards: root.evidence-cards; completion-card: root.completion-card; suggested-update: root.suggested-update; conversation-title: root.conversation-title; conversation-rows: root.conversation-rows; todo-url: root.todo-url; conversation-expanded-default: root.conversation-expanded-default; scan-inboxes => { root.scan-inboxes(); } stop-scan => { root.stop-scan(); } rescan => { root.rescan(); } retry-failed => { root.retry-failed(); } sync-todo => { root.sync-todo(); } clear-results => { root.clear-results(); } diff --git a/crates/openloops-desktop/ui/review.slint b/crates/openloops-desktop/ui/review.slint index 17a1d58..61aeae6 100644 --- a/crates/openloops-desktop/ui/review.slint +++ b/crates/openloops-desktop/ui/review.slint @@ -109,6 +109,11 @@ export component ReviewScreen { in-out property draft-when; in property draft-scheduled-line; in property draft-valid; + in property reminder-service; + in property reminder-time-note; + in property reminder-scope-note; + in property remind-button-label: "Set To Do reminder…"; + in property sync-reminders-label: "Sync To Do"; in property reminder-state; in property todo-url; in property source-url; @@ -170,7 +175,7 @@ export component ReviewScreen { if root.review-scanning: SecondaryButton { text: "â–  Stop scan"; accessible-name: "Stop scan"; clicked => { root.stop-scan(); } } if !root.review-scanning: PrimaryButton { text: "Scan inboxes"; enabled: root.can-scan; clicked => { root.scan-inboxes(); } } SubtleButton { text: "Rescan loaded mail"; enabled: root.can-rescan; clicked => { root.rescan(); } } - SubtleButton { text: "Sync To Do"; enabled: root.can-sync-todo; clicked => { root.sync-todo(); } } + SubtleButton { text: root.sync-reminders-label; enabled: root.can-sync-todo; clicked => { root.sync-todo(); } } SubtleButton { text: "Clear results and mail"; enabled: !root.busy; clicked => { root.clear-results(); } } Rectangle { horizontal-stretch: 1; } } @@ -436,7 +441,7 @@ export component ReviewScreen { if root.can-track: PrimaryButton { text: "Track — this is mine"; enabled: !root.busy; clicked => { root.decision(1); } } if root.can-watch: SecondaryButton { text: "Keep an eye on this"; enabled: !root.busy; clicked => { root.decision(4); } } Rectangle { horizontal-stretch: 1; } - OutlineButton { text: "Set To Do reminder…"; enabled: root.can-remind; clicked => { root.open-reminder(); } } + OutlineButton { text: root.remind-button-label; enabled: root.can-remind; clicked => { root.open-reminder(); } } } if root.selected-open: HorizontalLayout { width: 100%; spacing: Tokens.space-2; SecondaryButton { text: "Handled"; enabled: !root.busy; clicked => { root.decision(2); } } @@ -466,8 +471,8 @@ export component ReviewScreen { key-pressed(event) => { if event.text == Key.Escape { root.cancel-reminder(); return accept; } return reject; } Rectangle { width: 100%; height: draft-body.preferred-height; background: Tokens.brand-panel; border-color: Tokens.brand-stroke; border-width: Tokens.hairline; border-radius: Tokens.radius-6; draft-body := VerticalLayout { padding-left: Tokens.card-pad-x; padding-right: Tokens.card-pad-x; padding-top: Tokens.space-4; padding-bottom: Tokens.space-4; spacing: Tokens.space-3; alignment: start; - WrappedText { text: "Review your Microsoft To Do reminder"; text-color: Tokens.text-1; size: Tokens.font-15; weight: 600; } - WrappedText { text: "Creates one task in your personal default Tasks list. Only the title, reminder time, and an opaque OpenLoops reference are sent to Microsoft. This requires delegated Tasks.ReadWrite and a separate browser sign-in."; } + WrappedText { text: "Review your " + root.reminder-service + " reminder"; text-color: Tokens.text-1; size: Tokens.font-15; weight: 600; } + WrappedText { text: root.reminder-scope-note; } if reading-column.width >= Tokens.review-reading-max-width: HorizontalLayout { spacing: Tokens.space-3; Field { label: "Task title"; value <=> root.draft-title; enabled: !root.busy; horizontal-stretch: 2; edited(value) => { root.draft-title-edited(value); } } Field { label: "Remind me at (local time)"; placeholder: "YYYY-MM-DD HH:MM"; value <=> root.draft-when; monospace: true; enabled: !root.busy; horizontal-stretch: 1; edited(value) => { root.draft-when-edited(value); } } @@ -481,9 +486,9 @@ export component ReviewScreen { SecondaryButton { text: "Tomorrow at 9 am"; enabled: !root.busy; clicked => { root.draft-tomorrow(); } } WrappedText { text: root.draft-scheduled-line; text-color: root.draft-valid ? Tokens.text-2 : Tokens.danger; horizontal-stretch: 1; } } - WrappedText { text: "This time is your choice, separate from the email deadline. Microsoft To Do controls alert delivery, including when OpenLoops is closed. A failed or uncertain write is never retried automatically."; } + WrappedText { text: root.reminder-time-note; } HorizontalLayout { spacing: Tokens.space-2; alignment: start; - PrimaryButton { text: "Create this reminder in Microsoft To Do"; enabled: !root.busy && root.draft-valid; clicked => { root.create-reminder(); } } + PrimaryButton { text: "Create this reminder in " + root.reminder-service; enabled: !root.busy && root.draft-valid; clicked => { root.create-reminder(); } } SecondaryButton { text: "Cancel"; enabled: !root.busy; clicked => { root.cancel-reminder(); } } } } @@ -492,7 +497,7 @@ export component ReviewScreen { if root.reminder-state.state == "created": Callout { kind: "success"; text: root.reminder-state.text; accessible-name: root.reminder-state.text; HorizontalLayout { spacing: Tokens.space-2; Rectangle { horizontal-stretch: 1; } - LinkButton { text: "Open Microsoft To Do"; enabled: !root.busy; clicked => { root.open-external(root.todo-url); } } + LinkButton { text: "Open " + root.reminder-service; enabled: !root.busy; clicked => { root.open-external(root.todo-url); } } } } if root.reminder-state.state == "attempted": Callout { kind: "warning"; text: root.reminder-state.text; accessible-name: root.reminder-state.text; @@ -505,7 +510,7 @@ export component ReviewScreen { } HorizontalLayout { spacing: Tokens.space-2; Rectangle { horizontal-stretch: 1; } - LinkButton { text: "Inspect Microsoft To Do"; enabled: !root.busy; clicked => { root.open-external(root.todo-url); } } + LinkButton { text: "Inspect " + root.reminder-service; enabled: !root.busy; clicked => { root.open-external(root.todo-url); } } } HorizontalLayout { spacing: Tokens.space-2; alignment: start; SecondaryButton { text: "I checked: the task exists"; enabled: !root.busy; clicked => { root.reconcile-reminder(true); } } diff --git a/crates/openloops-desktop/ui/sources.slint b/crates/openloops-desktop/ui/sources.slint index c1f6b06..1b9ffbf 100644 --- a/crates/openloops-desktop/ui/sources.slint +++ b/crates/openloops-desktop/ui/sources.slint @@ -52,7 +52,6 @@ export component SourcesScreen { in-out property client-id; in-out property groups; in-out property shared-mailboxes; - in property own-inbox-accessible; in property <[StatusLine]> microsoft-lines; // Busy/progress text shown while a job targets this card -- kept out of // `microsoft-lines` (which stays `Tokens.success`/`Tokens.warning` only) @@ -60,6 +59,15 @@ export component SourcesScreen { // round: "should be text-2"). in property microsoft-busy-line; in-out property mail-provider-index; + // Card 1 heading pills, computed in Rust: "Personal inbox confirmed" with + // one provider enabled, else one " connected" per connection. + in property <[string]> heading-pills; + // Whether the selected provider is included in scans. + in-out property provider-enabled; + // "Scans include: ..." when more than one provider is enabled; else empty. + in property scan-providers-text; + in property scope-folders-label: "Inbox / Sent Items"; + in property scope-groups-text: "20 recent threads · 40 posts each"; in property shipped-google-registration-present; in-out property google-client-id; in-out property google-client-secret; @@ -105,6 +113,7 @@ export component SourcesScreen { callback groups-edited(string); callback shared-edited(string); callback mail-provider-selected(int); + callback provider-enabled-toggled(bool); callback google-client-id-edited(string); callback google-client-secret-edited(string); callback open-google-console; @@ -154,10 +163,12 @@ export component SourcesScreen { 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 your mailbox"; - if root.own-inbox-accessible: Pill { text: "Personal inbox confirmed"; kind: "success"; } + for pill in root.heading-pills: Pill { text: pill; 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); } } + Checkbox { text: "Include in scans"; checked <=> root.provider-enabled; enabled: root.controls-enabled; toggled(value) => { root.provider-enabled-toggled(value); } } + if !root.scan-providers-text.is-empty: HelperText { text: root.scan-providers-text; width: 100%; } 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%; } @@ -293,8 +304,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: 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: root.scope-folders-label; 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.scope-groups-text; 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 @@ -304,7 +315,7 @@ export component SourcesScreen { HelperText { text: "Subjects, current text, quoted history and participants are sent to the selected provider (" + root.provider-disclosure + "). Attachments are not sent. Scans run when you click Scan; this preview is not an unattended background service."; width: 100%; } } } - HelperText { text: "Settings and abstract loop decisions save in Windows Credential Manager. Mail and descriptions remain in memory; scan to reconstruct them after restart. Reviewed reminders live in Microsoft To Do. Outlook add-in integration is a separate step."; width: 100%; } + HelperText { text: "Settings and abstract loop decisions save in Windows Credential Manager. Mail and descriptions remain in memory; scan to reconstruct them after restart. Reviewed reminders live in Microsoft To Do or Google Tasks, depending on the mailbox. Outlook add-in integration is a separate step."; width: 100%; } // `Tokens.text-2`: this line sits on the window's own // `bg-canvas` (the scroll body sets no opaque background of // its own), where `text-3` falls to 4.35:1 -- below the diff --git a/crates/openloops-graph/src/live.rs b/crates/openloops-graph/src/live.rs index 18c7756..53ad282 100644 --- a/crates/openloops-graph/src/live.rs +++ b/crates/openloops-graph/src/live.rs @@ -13,7 +13,7 @@ pub mod review; #[cfg(test)] mod test_support; -pub use provider::{AccountConfig, MailProvider, ProviderLoad, load_all}; +pub use provider::{AccountConfig, MailProvider, ProviderLoad, load_all, load_selected}; use std::collections::BTreeSet; use std::io::Read; @@ -35,6 +35,8 @@ const TOKEN_TIMEOUT_SECONDS: u64 = 30; const GRAPH_TIMEOUT_SECONDS: u64 = 90; static CONNECTING: AtomicBool = AtomicBool::new(false); static SESSIONS: Mutex<[Option; 2]> = Mutex::new([None, None]); +#[cfg(test)] +static SESSION_TEST: Mutex<()> = Mutex::new(()); pub(super) struct OAuthEndpoints { pub authorize: &'static str, @@ -712,8 +714,6 @@ mod tests { use super::test_support::{routed_server, scripted_server}; use super::*; const APP: &str = "11111111-1111-4111-8111-111111111111"; - static SESSION_TEST: Mutex<()> = Mutex::new(()); - fn synthetic_session(scopes: BTreeSet, lifetime: Duration) -> Session { Session { access_token: Secret::new("synthetic-token".to_owned()), diff --git a/crates/openloops-graph/src/live/google/gmail.rs b/crates/openloops-graph/src/live/google/gmail.rs index bbbabb6..3f7730c 100644 --- a/crates/openloops-graph/src/live/google/gmail.rs +++ b/crates/openloops-graph/src/live/google/gmail.rs @@ -67,6 +67,8 @@ pub(super) fn identity_from_origin( /// Loads both recent Gmail sources into memory. /// +/// Call [`LoadProgress::begin`] once per job before this. Loaders never call it. +/// /// # Errors /// Returns fixed, content-free configuration, authorization, transport, or cancellation errors. pub fn load_recent_with( @@ -79,6 +81,8 @@ pub fn load_recent_with( /// Loads selected recent Gmail sources into memory. /// +/// Call [`LoadProgress::begin`] once per job before this. Loaders never call it. +/// /// # Errors /// Returns fixed, content-free configuration, authorization, transport, or cancellation errors. pub fn load_sources_with( @@ -90,7 +94,9 @@ pub fn load_sources_with( if progress.cancel.load(Ordering::Acquire) { return Err(ConnectionError::Cancelled); } + let start = progress.checkpoint(); with_google_session(config, false, |http, token| { + progress.rewind(&start); load_sources_from_origins( http, token, @@ -103,6 +109,14 @@ pub fn load_sources_with( }) } +/// How many Gmail labels a load with `filter` plans to read. +pub(in crate::live) fn source_count(filter: Option<&BTreeSet>) -> usize { + LABELS + .iter() + .filter(|(_, label)| selected(filter, label)) + .count() +} + fn selected(filter: Option<&BTreeSet>, label: &str) -> bool { filter.is_none_or(|filter| { filter.contains(label) @@ -121,16 +135,6 @@ fn load_sources_from_origins( 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 { @@ -771,4 +775,53 @@ mod tests { assert!(sources[0].messages.is_empty()); assert!(sources[0].message_errors.is_empty()); } + + #[test] + fn loader_adds_to_progress_left_by_an_earlier_provider() { + let listing = serde_json::json!({"messages":[ + {"id":"synthetic-cached","threadId":"thread-cached"} + ]}) + .to_string(); + let (origin, _, 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 progress = LoadProgress::default(); + progress.begin(3); + progress.listed.store(4, Ordering::Relaxed); + progress.loaded.store(4, Ordering::Relaxed); + progress.sources_done.store(2, Ordering::Relaxed); + let http = Client::builder().no_proxy().build().unwrap(); + let filter = BTreeSet::from(["Gmail / Inbox".to_owned()]); + load_sources_from_origins( + &http, + "synthetic-token", + &cache, + &progress, + Some(&filter), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(progress.listed.load(Ordering::Relaxed), 5); + assert_eq!(progress.loaded.load(Ordering::Relaxed), 5); + assert_eq!(progress.sources_done.load(Ordering::Relaxed), 3); + assert_eq!(progress.sources_total.load(Ordering::Relaxed), 3); + } } diff --git a/crates/openloops-graph/src/live/google/mod.rs b/crates/openloops-graph/src/live/google/mod.rs index 80a0f66..b63719c 100644 --- a/crates/openloops-graph/src/live/google/mod.rs +++ b/crates/openloops-graph/src/live/google/mod.rs @@ -2,6 +2,7 @@ pub mod gmail; mod mime; +pub mod tasks; use std::collections::BTreeSet; use std::sync::atomic::Ordering; diff --git a/crates/openloops-graph/src/live/google/tasks.rs b/crates/openloops-graph/src/live/google/tasks.rs new file mode 100644 index 0000000..2472a0a --- /dev/null +++ b/crates/openloops-graph/src/live/google/tasks.rs @@ -0,0 +1,783 @@ +//! Explicit Google Tasks reminder writes. Google Tasks keeps only the date +//! part of `due` exactly as sent and drops the time, so `due` carries the +//! user's LOCAL calendar date of the chosen time (as midnight `Z`), never the +//! UTC date. The reviewed local time, with its UTC offset, is retained in +//! `notes` and does not create an alert. + +use reqwest::{Method, blocking::Client}; +use serde_json::{Value, json}; +use url::Url; + +use super::{GoogleConfig, gmail, with_google_session}; +use crate::live::reminders::{ + ReminderCompletionOutcome, ReminderFailure, ReminderOutcome, ReminderRequest, + TaskStatusOutcome, format_local_reminder_time, valid_remote_id, validate_request, +}; +use crate::live::review::fetch_from_origin_with_headers; +use crate::live::{ + ConnectionError, GRAPH_TIMEOUT_SECONDS, MailProvider, bounded_body, request_error, +}; + +pub(super) const TASKS_ORIGIN: &str = "https://tasks.googleapis.com/"; + +fn task_body(request: &ReminderRequest) -> Result, ReminderFailure> { + validate_request(request)?; + let due = chrono::DateTime::from_timestamp(request.at_utc, 0) + .ok_or(ReminderFailure::InvalidDraft)? + .with_timezone(&chrono::Local) + .format("%Y-%m-%dT00:00:00.000Z") + .to_string(); + let local = format_local_reminder_time(request.at_utc).ok_or(ReminderFailure::InvalidDraft)?; + serde_json::to_vec(&json!({ + "title": request.title, + "status": "needsAction", + "due": due, + "notes": format!( + "Created after review in OpenLoops.\nReminder time: {local}\nOpenLoops reference: {}", + request.marker + ) + })) + .map_err(|_| ReminderFailure::InvalidDraft) +} + +fn url_with_segments(origin: &str, base: &str, segments: &[&str]) -> Result { + let mut url = Url::parse(origin) + .and_then(|url| url.join(base)) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + let mut path = url + .path_segments_mut() + .map_err(|()| ConnectionError::InvalidConfiguration)?; + path.pop_if_empty(); + for segment in segments { + path.push(segment); + } + drop(path); + Ok(url) +} + +fn write_from_origin( + http: &Client, + token: &str, + method: Method, + url: Url, + expected_origin: &str, + body: &[u8], +) -> Result<(u16, Vec), ConnectionError> { + let expected = + Url::parse(expected_origin).map_err(|_| ConnectionError::InvalidConfiguration)?; + if url.origin() != expected.origin() { + return Err(ConnectionError::InvalidConfiguration); + } + let response = http + .request(method, url) + .bearer_auth(token) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(GRAPH_TIMEOUT_SECONDS)) + .body(body.to_vec()) + .send() + .map_err(|error| request_error(&error, GRAPH_TIMEOUT_SECONDS))?; + let status = response.status().as_u16(); + let bytes = bounded_body(response)?; + Ok((status, bytes)) +} + +fn reminder_result(result: Result) -> ReminderOutcome { + match result { + Ok(outcome) => outcome, + Err(error) => ReminderOutcome::NotCreated(ReminderFailure::Rejected(error)), + } +} + +fn create_from_origins( + http: &Client, + token: &str, + request: &ReminderRequest, + body: &[u8], + userinfo_origin: &str, + tasks_origin: &str, +) -> Result { + let identity = gmail::identity_from_origin(http, token, userinfo_origin)?; + if identity.account != request.account { + return Ok(ReminderOutcome::NotCreated( + ReminderFailure::AccountMismatch, + )); + } + let mut lists_url = Url::parse(tasks_origin) + .and_then(|url| url.join("tasks/v1/users/@me/lists")) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + lists_url.query_pairs_mut().append_pair("maxResults", "100"); + let bytes = fetch_from_origin_with_headers(http, token, &lists_url, tasks_origin, &[])?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| ConnectionError::ResourceUnavailable)?; + if !value.is_object() { + return Err(ConnectionError::ResourceUnavailable); + } + // Google omits empty repeated fields: no `items` key is an empty list. + let items = match value.get("items") { + None => &[][..], + Some(items) => items + .as_array() + .ok_or(ConnectionError::ResourceUnavailable)? + .as_slice(), + }; + if items.len() > 100 { + return Err(ConnectionError::ResponseTooLarge); + } + let Some(list_id) = items + .first() + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .filter(|id| valid_remote_id(id)) + else { + return Ok(ReminderOutcome::NotCreated( + ReminderFailure::DefaultListNotFound, + )); + }; + let url = url_with_segments(tasks_origin, "tasks/v1/lists/", &[list_id, "tasks"])?; + let response = write_from_origin(http, token, Method::POST, url, tasks_origin, body); + let Ok((status, bytes)) = response else { + return Ok(ReminderOutcome::Uncertain); + }; + if status == 401 { + crate::live::clear_session_for(MailProvider::Google); + return Ok(ReminderOutcome::Uncertain); + } + if status != 200 { + return Ok(ReminderOutcome::Uncertain); + } + let Ok(value) = serde_json::from_slice::(&bytes) else { + return Ok(ReminderOutcome::Uncertain); + }; + match value + .get("id") + .and_then(Value::as_str) + .filter(|id| valid_remote_id(id)) + { + Some(task_id) => Ok(ReminderOutcome::Created { + provider: MailProvider::Google, + list_id: list_id.to_owned(), + task_id: task_id.to_owned(), + }), + None => Ok(ReminderOutcome::Uncertain), + } +} + +/// Creates one reviewed task in the signed-in account's first task list. +#[must_use] +pub fn create(config: &GoogleConfig, request: &ReminderRequest) -> ReminderOutcome { + if request.provider != MailProvider::Google { + return ReminderOutcome::NotCreated(ReminderFailure::InvalidDraft); + } + let body = match task_body(request) { + Ok(body) => body, + Err(error) => return ReminderOutcome::NotCreated(error), + }; + reminder_result(with_google_session(config, true, |http, token| { + create_from_origins( + http, + token, + request, + &body, + gmail::USERINFO_ORIGIN, + TASKS_ORIGIN, + ) + })) +} + +fn complete_from_origins( + http: &Client, + token: &str, + account: &str, + list_id: &str, + task_id: &str, + origins: (&str, &str), + dispatched: &mut bool, +) -> Result<(), ConnectionError> { + let (userinfo_origin, tasks_origin) = origins; + if !valid_remote_id(list_id) || !valid_remote_id(task_id) { + return Err(ConnectionError::InvalidConfiguration); + } + if gmail::identity_from_origin(http, token, userinfo_origin)?.account != account { + return Err(ConnectionError::InvalidConfiguration); + } + let url = url_with_segments( + tasks_origin, + "tasks/v1/lists/", + &[list_id, "tasks", task_id], + )?; + let body = serde_json::to_vec(&json!({"status": "completed"})) + .map_err(|_| ConnectionError::InvalidConfiguration)?; + *dispatched = true; + let (status, _) = write_from_origin(http, token, Method::PATCH, url, tasks_origin, &body)?; + // Mapped like the Microsoft `complete`: a 401 is cleared here and never + // surfaces as `Unauthorized`, so the session runner cannot re-send the PATCH. + match status { + 200 => Ok(()), + 404 => Err(ConnectionError::NotFound), + 401 => { + crate::live::clear_session_for(MailProvider::Google); + Err(ConnectionError::ResourceUnavailable) + } + _ => Err(ConnectionError::ResourceUnavailable), + } +} + +/// Marks an existing Google task completed. +#[must_use] +pub fn complete( + config: &GoogleConfig, + account: &str, + list_id: &str, + task_id: &str, +) -> ReminderCompletionOutcome { + if !valid_remote_id(list_id) || !valid_remote_id(task_id) { + return ReminderCompletionOutcome::NotCompleted(ConnectionError::InvalidConfiguration); + } + let mut dispatched = false; + let result = with_google_session(config, true, |http, token| { + complete_from_origins( + http, + token, + account, + list_id, + task_id, + (gmail::USERINFO_ORIGIN, TASKS_ORIGIN), + &mut dispatched, + ) + }); + match result { + Ok(()) => ReminderCompletionOutcome::Completed, + Err(_) if dispatched => ReminderCompletionOutcome::Uncertain, + Err(error) => ReminderCompletionOutcome::NotCompleted(error), + } +} + +fn status_from_origins( + http: &Client, + token: &str, + account: &str, + list_id: &str, + task_id: &str, + userinfo_origin: &str, + tasks_origin: &str, +) -> Result { + if !valid_remote_id(list_id) || !valid_remote_id(task_id) { + return Err(ConnectionError::InvalidConfiguration); + } + if gmail::identity_from_origin(http, token, userinfo_origin)?.account != account { + return Err(ConnectionError::InvalidConfiguration); + } + let url = url_with_segments( + tasks_origin, + "tasks/v1/lists/", + &[list_id, "tasks", task_id], + )?; + let bytes = fetch_from_origin_with_headers(http, token, &url, tasks_origin, &[])?; + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| ConnectionError::ResourceUnavailable)?; + Ok(value.get("status").and_then(Value::as_str) == Some("completed")) +} + +/// Checks whether an existing Google task is completed. +#[must_use] +pub fn check_status( + config: &GoogleConfig, + account: &str, + list_id: &str, + task_id: &str, +) -> TaskStatusOutcome { + if !valid_remote_id(list_id) || !valid_remote_id(task_id) { + return TaskStatusOutcome::Unknown(ConnectionError::InvalidConfiguration); + } + let result = with_google_session(config, true, |http, token| { + status_from_origins( + http, + token, + account, + list_id, + task_id, + gmail::USERINFO_ORIGIN, + TASKS_ORIGIN, + ) + }); + match result { + Ok(true) => TaskStatusOutcome::Completed, + Ok(false) => TaskStatusOutcome::NotCompleted, + Err(error) => TaskStatusOutcome::Unknown(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::live::test_support::{recording_routed_server, routed_server}; + use std::sync::atomic::Ordering; + + 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 request() -> ReminderRequest { + ReminderRequest { + provider: MailProvider::Google, + account: "google:synthetic-sub".into(), + title: "Review the draft".into(), + at_utc: 4_000_000_000, + marker: "a".repeat(64), + } + } + + fn identity() -> Vec { + response( + "200 OK", + r#"{"sub":"synthetic-sub","email":"user@example.invalid"}"#, + ) + } + + fn client() -> Client { + Client::builder().no_proxy().build().unwrap() + } + + fn local_due(at_utc: i64) -> String { + chrono::DateTime::from_timestamp(at_utc, 0) + .unwrap() + .with_timezone(&chrono::Local) + .format("%Y-%m-%dT00:00:00.000Z") + .to_string() + } + + #[test] + fn due_is_the_local_calendar_date_of_the_chosen_time() { + use chrono::{Offset, TimeZone}; + // A local time near midnight on the side where the UTC date differs + // from the local date (late evening west of UTC, early morning east). + let probe = chrono::Local + .with_ymd_and_hms(2096, 10, 2, 12, 0, 0) + .single() + .unwrap(); + let hour = if probe.offset().fix().local_minus_utc() < 0 { + 23 + } else { + 0 + }; + let local = chrono::Local + .with_ymd_and_hms(2096, 10, 2, hour, 30, 0) + .single() + .unwrap(); + let mut request = request(); + request.at_utc = local.timestamp(); + let value: Value = serde_json::from_slice(&task_body(&request).unwrap()).unwrap(); + assert_eq!(value["due"], "2096-10-02T00:00:00.000Z"); + } + + #[test] + fn body_has_date_only_due_and_local_time_in_notes() { + let request = request(); + let value: Value = serde_json::from_slice(&task_body(&request).unwrap()).unwrap(); + assert_eq!(value["title"], "Review the draft"); + assert_eq!(value["status"], "needsAction"); + assert_eq!(value["due"], local_due(request.at_utc)); + assert_eq!( + value["notes"], + format!( + "Created after review in OpenLoops.\nReminder time: {}\nOpenLoops reference: {}", + format_local_reminder_time(request.at_utc).unwrap(), + "a".repeat(64) + ) + ); + assert_eq!(value.as_object().unwrap().len(), 4); + } + + #[test] + fn account_mismatch_stops_after_userinfo() { + let (origin, calls, server) = routed_server(vec![( + "/v1/userinfo", + response( + "200 OK", + r#"{"sub":"different-sub","email":"user@example.invalid"}"#, + ), + )]); + let request = request(); + let body = task_body(&request).unwrap(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &body, + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!( + outcome, + ReminderOutcome::NotCreated(ReminderFailure::AccountMismatch) + ); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } + + #[test] + fn empty_or_incomplete_list_set_has_no_default() { + for lists in [ + r#"{"items":[]}"#, + r#"{"items":[],"nextPageToken":"more"}"#, + r#"{"kind":"tasks#taskLists"}"#, + ] { + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ("/tasks/v1/users/@me/lists", response("200 OK", lists)), + ]); + let request = request(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &task_body(&request).unwrap(), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!( + outcome, + ReminderOutcome::NotCreated(ReminderFailure::DefaultListNotFound) + ); + assert_eq!(calls.load(Ordering::Relaxed), 2); + } + } + + #[test] + fn successful_create_keeps_concrete_list_and_task_ids() { + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/users/@me/lists", + response("200 OK", r#"{"items":[{"id":"list-id"}]}"#), + ), + ( + "/tasks/v1/lists/list-id/tasks", + response("200 OK", r#"{"id":"task-id"}"#), + ), + ]); + let request = request(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &task_body(&request).unwrap(), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!( + outcome, + ReminderOutcome::Created { + provider: MailProvider::Google, + list_id: "list-id".into(), + task_id: "task-id".into(), + } + ); + assert_eq!(calls.load(Ordering::Relaxed), 3); + } + + #[test] + fn success_without_task_id_is_uncertain() { + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/users/@me/lists", + response("200 OK", r#"{"items":[{"id":"list-id"}]}"#), + ), + ("/tasks/v1/lists/list-id/tasks", response("200 OK", "{}")), + ]); + let request = request(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &task_body(&request).unwrap(), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(outcome, ReminderOutcome::Uncertain); + } + + #[test] + fn post_unauthorized_clears_only_the_google_session() { + let _serial = crate::live::SESSION_TEST.lock().unwrap(); + crate::live::clear_all_sessions(); + let session = || crate::live::Session { + access_token: crate::live::Secret::new("synthetic-token".to_owned()), + expires_at: std::time::Instant::now() + std::time::Duration::from_mins(2), + scopes: std::collections::BTreeSet::from(["scope".to_owned()]), + shared: crate::live::SharedScope::NotReported, + }; + crate::live::session_store()[MailProvider::Microsoft.slot()] = Some(session()); + crate::live::session_store()[MailProvider::Google.slot()] = Some(session()); + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/users/@me/lists", + response("200 OK", r#"{"items":[{"id":"list-id"}]}"#), + ), + ( + "/tasks/v1/lists/list-id/tasks", + response("401 Unauthorized", ""), + ), + ]); + let request = request(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &task_body(&request).unwrap(), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(outcome, ReminderOutcome::Uncertain); + assert!(crate::live::has_session_for(MailProvider::Microsoft)); + assert!(!crate::live::has_session_for(MailProvider::Google)); + crate::live::clear_all_sessions(); + } + + fn create_with_post_response(post: Vec) -> ReminderOutcome { + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/users/@me/lists", + response("200 OK", r#"{"items":[{"id":"list-id"}]}"#), + ), + ("/tasks/v1/lists/list-id/tasks", post), + ]); + let request = request(); + let outcome = create_from_origins( + &client(), + "synthetic-token", + &request, + &task_body(&request).unwrap(), + &origin, + &origin, + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 3); + outcome + } + + #[test] + fn post_non_success_status_is_uncertain() { + for status in [ + "201 Created", + "400 Bad Request", + "500 Internal Server Error", + ] { + assert_eq!( + create_with_post_response(response(status, r#"{"id":"task-id"}"#)), + ReminderOutcome::Uncertain + ); + } + } + + #[test] + fn post_transport_failure_after_dispatch_is_uncertain() { + // The server accepts the POST and closes without any response bytes. + assert_eq!( + create_with_post_response(Vec::new()), + ReminderOutcome::Uncertain + ); + } + + #[test] + fn patch_sends_exact_completed_body_to_the_concrete_task_path() { + let (origin, requests, server) = recording_routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/task-id", + response("200 OK", r#"{"status":"completed"}"#), + ), + ]); + let mut dispatched = false; + complete_from_origins( + &client(), + "synthetic-token", + "google:synthetic-sub", + "list-id", + "task-id", + (&origin, &origin), + &mut dispatched, + ) + .unwrap(); + server.join().unwrap(); + assert!(dispatched); + let requests = requests.lock().unwrap(); + let patch = &requests[1]; + assert!(patch.starts_with("PATCH /tasks/v1/lists/list-id/tasks/task-id HTTP/1.1\r\n")); + let body = &patch[patch.find("\r\n\r\n").unwrap() + 4..]; + assert_eq!(body, r#"{"status":"completed"}"#); + } + + #[test] + fn patch_status_mapping_matches_microsoft_complete() { + for (status, expected) in [ + ("404 Not Found", ConnectionError::NotFound), + ("401 Unauthorized", ConnectionError::ResourceUnavailable), + ("403 Forbidden", ConnectionError::ResourceUnavailable), + ( + "500 Internal Server Error", + ConnectionError::ResourceUnavailable, + ), + ] { + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/task-id", + response(status, ""), + ), + ]); + let mut dispatched = false; + let result = complete_from_origins( + &client(), + "synthetic-token", + "google:synthetic-sub", + "list-id", + "task-id", + (&origin, &origin), + &mut dispatched, + ); + server.join().unwrap(); + assert_eq!(result, Err(expected)); + assert!(dispatched); + } + } + + #[test] + fn patch_unauthorized_clears_google_session_and_never_patches_again() { + let _serial = crate::live::SESSION_TEST.lock().unwrap(); + crate::live::clear_all_sessions(); + let scopes = std::collections::BTreeSet::from(["scope".to_owned()]); + let session = || crate::live::Session { + access_token: crate::live::Secret::new("synthetic-token".to_owned()), + expires_at: std::time::Instant::now() + std::time::Duration::from_mins(2), + scopes: scopes.clone(), + shared: crate::live::SharedScope::NotReported, + }; + crate::live::session_store()[MailProvider::Microsoft.slot()] = Some(session()); + crate::live::session_store()[MailProvider::Google.slot()] = Some(session()); + let (origin, requests, server) = recording_routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/task-id", + response("401 Unauthorized", ""), + ), + ]); + let http = client(); + let mut authorizations = 0; + let mut dispatched = false; + let result = crate::live::run_with_session_for( + MailProvider::Google, + scopes.clone(), + |_| { + authorizations += 1; + Ok(session()) + }, + |token, _| { + complete_from_origins( + &http, + token, + "google:synthetic-sub", + "list-id", + "task-id", + (&origin, &origin), + &mut dispatched, + ) + }, + ); + server.join().unwrap(); + assert!(result.is_err()); + assert_eq!(authorizations, 0); + let patches = requests + .lock() + .unwrap() + .iter() + .filter(|request| request.starts_with("PATCH ")) + .count(); + assert_eq!(patches, 1); + assert!(crate::live::has_session_for(MailProvider::Microsoft)); + assert!(!crate::live::has_session_for(MailProvider::Google)); + crate::live::clear_all_sessions(); + } + + #[test] + fn patch_and_get_use_the_concrete_task_path() { + let (origin, calls, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/task-id", + response("200 OK", r#"{"status":"completed"}"#), + ), + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/task-id", + response("200 OK", r#"{"status":"completed"}"#), + ), + ]); + let http = client(); + let mut dispatched = false; + complete_from_origins( + &http, + "synthetic-token", + "google:synthetic-sub", + "list-id", + "task-id", + (&origin, &origin), + &mut dispatched, + ) + .unwrap(); + assert!(dispatched); + assert!( + status_from_origins( + &http, + "synthetic-token", + "google:synthetic-sub", + "list-id", + "task-id", + &origin, + &origin, + ) + .unwrap() + ); + server.join().unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 4); + } + + #[test] + fn status_not_found_is_preserved() { + let (origin, _, server) = routed_server(vec![ + ("/v1/userinfo", identity()), + ( + "/tasks/v1/lists/list-id/tasks/missing-id", + response("404 Not Found", ""), + ), + ]); + let result = status_from_origins( + &client(), + "synthetic-token", + "google:synthetic-sub", + "list-id", + "missing-id", + &origin, + &origin, + ); + server.join().unwrap(); + assert_eq!(result, Err(ConnectionError::NotFound)); + } +} diff --git a/crates/openloops-graph/src/live/provider.rs b/crates/openloops-graph/src/live/provider.rs index d5abb86..4af234a 100644 --- a/crates/openloops-graph/src/live/provider.rs +++ b/crates/openloops-graph/src/live/provider.rs @@ -1,6 +1,9 @@ use std::collections::BTreeSet; use super::google::GoogleConfig; +use super::reminders::{ + ReminderCompletionOutcome, ReminderOutcome, ReminderRequest, TaskStatusOutcome, +}; use super::review::{LoadProgress, MailCache, SourceReview}; use super::{ConnectionConfig, ConnectionError, ConnectionReport}; @@ -82,6 +85,51 @@ pub enum AccountConfig { Google(GoogleConfig), } +#[must_use] +pub fn create_reminder(account: &AccountConfig, request: &ReminderRequest) -> ReminderOutcome { + if account.provider() != request.provider { + return ReminderOutcome::NotCreated(super::reminders::ReminderFailure::InvalidDraft); + } + match account { + AccountConfig::Microsoft(config) => super::reminders::create(config, request), + AccountConfig::Google(config) => super::google::tasks::create(config, request), + } +} + +#[must_use] +pub fn complete_reminder( + account: &AccountConfig, + identity: &str, + list_id: &str, + task_id: &str, +) -> ReminderCompletionOutcome { + match account { + AccountConfig::Microsoft(config) => { + super::reminders::complete(config, identity, list_id, task_id) + } + AccountConfig::Google(config) => { + super::google::tasks::complete(config, identity, list_id, task_id) + } + } +} + +#[must_use] +pub fn reminder_status( + account: &AccountConfig, + identity: &str, + list_id: &str, + task_id: &str, +) -> TaskStatusOutcome { + match account { + AccountConfig::Microsoft(config) => { + super::reminders::check_status(config, identity, list_id, task_id) + } + AccountConfig::Google(config) => { + super::google::tasks::check_status(config, identity, list_id, task_id) + } + } +} + impl AccountConfig { #[must_use] pub const fn provider(&self) -> MailProvider { @@ -102,8 +150,20 @@ impl AccountConfig { } } + /// How many sources a load with `filter` plans to read; the progress total. + #[must_use] + pub fn source_count(&self, filter: Option<&BTreeSet>) -> usize { + match self { + Self::Microsoft(config) => super::review::source_count(config, filter), + Self::Google(_) => super::google::gmail::source_count(filter), + } + } + /// Loads recent sources through the configured provider. /// + /// Call [`LoadProgress::begin`] once per job before this. Loaders never + /// call it. + /// /// # Errors /// Returns a fixed, content-free provider or loading error. pub fn load_recent_with( @@ -116,6 +176,9 @@ impl AccountConfig { /// Loads selected sources through the configured provider. /// + /// Call [`LoadProgress::begin`] once per job before this. Loaders never + /// call it. + /// /// # Errors /// Returns a fixed, content-free provider or loading error. pub fn load_sources_with( @@ -140,18 +203,48 @@ pub struct ProviderLoad { pub sources: Result, ConnectionError>, } +/// Loads every account in order. Progress starts once over all accounts; one +/// account's error never stops the next. pub fn load_all( accounts: &[AccountConfig], cache: &MailCache, progress: &LoadProgress, ) -> Vec { - accounts - .iter() - .map(|account| ProviderLoad { - provider: account.provider(), - sources: account.load_recent_with(cache, progress), - }) - .collect() + load_each( + accounts.iter().map(|account| (account, None)), + cache, + progress, + ) +} + +/// Loads only the given source labels of each account, in order, as one job. +pub fn load_selected( + jobs: &[(AccountConfig, BTreeSet)], + cache: &MailCache, + progress: &LoadProgress, +) -> Vec { + load_each( + jobs.iter().map(|(account, labels)| (account, Some(labels))), + cache, + progress, + ) +} + +fn load_each<'a>( + jobs: impl Iterator>)> + Clone, + cache: &MailCache, + progress: &LoadProgress, +) -> Vec { + progress.begin( + jobs.clone() + .map(|(account, filter)| account.source_count(filter)) + .sum(), + ); + jobs.map(|(account, filter)| ProviderLoad { + provider: account.provider(), + sources: account.load_sources_with(cache, progress, filter), + }) + .collect() } #[cfg(test)] @@ -178,6 +271,46 @@ mod tests { ); } + #[test] + fn reminder_requests_for_the_other_provider_are_rejected_without_network() { + use super::super::reminders::{ReminderFailure, ReminderRequest}; + let request = |provider| ReminderRequest { + provider, + account: "synthetic-account".into(), + title: "Review the draft".into(), + at_utc: 4_000_000_000, + marker: "a".repeat(64), + }; + let microsoft = + || ConnectionConfig::new("11111111-1111-4111-8111-111111111111", None).unwrap(); + let google = || { + GoogleConfig::new("123-synthetic.apps.googleusercontent.com", "fixture-secret").unwrap() + }; + let rejected = ReminderOutcome::NotCreated(ReminderFailure::InvalidDraft); + assert_eq!( + create_reminder( + &AccountConfig::Google(google()), + &request(MailProvider::Microsoft) + ), + rejected + ); + assert_eq!( + create_reminder( + &AccountConfig::Microsoft(microsoft()), + &request(MailProvider::Google) + ), + rejected + ); + assert_eq!( + super::super::google::tasks::create(&google(), &request(MailProvider::Microsoft)), + rejected + ); + assert_eq!( + super::super::reminders::create(µsoft(), &request(MailProvider::Google)), + rejected + ); + } + #[test] fn load_all_keeps_going_after_provider_error() { let accounts = [ @@ -193,4 +326,83 @@ mod tests { .all(|load| matches!(load.sources, Err(ConnectionError::InvalidConfiguration))) ); } + + #[test] + fn source_count_follows_configured_labels_and_filter() { + let microsoft = AccountConfig::Microsoft( + ConnectionConfig::new( + "11111111-1111-4111-8111-111111111111", + Some("shared@example.invalid"), + ) + .and_then(|config| config.with_groups(Some("group@example.invalid"))) + .unwrap(), + ); + assert_eq!(microsoft.source_count(None), 5); + let filter = BTreeSet::from(["shared@example.invalid".to_owned()]); + assert_eq!(microsoft.source_count(Some(&filter)), 2); + let google = AccountConfig::Google(GoogleConfig::invalid_for_test()); + assert_eq!(google.source_count(None), 2); + let filter = BTreeSet::from(["Gmail / Sent".to_owned()]); + assert_eq!(google.source_count(Some(&filter)), 1); + } + + #[test] + fn load_all_begins_progress_once_with_every_account_total() { + let accounts = [ + AccountConfig::Google(GoogleConfig::invalid_for_test()), + AccountConfig::Google(GoogleConfig::invalid_for_test()), + ]; + let progress = LoadProgress::default(); + progress + .listed + .store(9, std::sync::atomic::Ordering::Relaxed); + progress + .sources_done + .store(3, std::sync::atomic::Ordering::Relaxed); + let _ = load_all(&accounts, &MailCache::default(), &progress); + assert_eq!( + progress + .sources_total + .load(std::sync::atomic::Ordering::Relaxed), + 4 + ); + assert_eq!( + progress.listed.load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + assert_eq!( + progress + .sources_done + .load(std::sync::atomic::Ordering::Relaxed), + 0 + ); + } + + #[test] + fn load_selected_runs_each_account_with_its_own_labels() { + let jobs = [ + ( + AccountConfig::Google(GoogleConfig::invalid_for_test()), + BTreeSet::from(["Gmail / Inbox".to_owned()]), + ), + ( + AccountConfig::Google(GoogleConfig::invalid_for_test()), + BTreeSet::from(["Gmail / Inbox".to_owned(), "Gmail / Sent".to_owned()]), + ), + ]; + let progress = LoadProgress::default(); + let loads = load_selected(&jobs, &MailCache::default(), &progress); + assert_eq!(loads.len(), 2); + assert!( + loads + .iter() + .all(|load| load.provider == MailProvider::Google) + ); + assert_eq!( + progress + .sources_total + .load(std::sync::atomic::Ordering::Relaxed), + 3 + ); + } } diff --git a/crates/openloops-graph/src/live/reminders.rs b/crates/openloops-graph/src/live/reminders.rs index 037d375..7527545 100644 --- a/crates/openloops-graph/src/live/reminders.rs +++ b/crates/openloops-graph/src/live/reminders.rs @@ -50,12 +50,28 @@ impl std::fmt::Display for ReminderFailure { } } +impl ReminderFailure { + #[must_use] + pub fn describe(self, provider: MailProvider) -> String { + match (self, provider) { + (Self::DefaultListNotFound, MailProvider::Google) => "Google Tasks did not return a default list for this account. Open Google Tasks once, then try again.".to_owned(), + (Self::Rejected(error), MailProvider::Google) + if matches!(error, ConnectionError::AccessDenied | ConnectionError::Unauthorized) => + { + format!("Google refused the Tasks write. The app registration needs the Google Tasks scope and the signed-in account must consent to it. {error}") + } + _ => self.to_string(), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ReminderOutcome { - /// The task list and task id Graph reported, so a later action (e.g. + /// The provider plus the task list and task id it reported, so a later action (e.g. /// marking it complete once the review card is Handled) can address the /// same task without re-resolving the default list. Created { + provider: MailProvider, list_id: String, task_id: String, }, @@ -73,24 +89,15 @@ pub enum ReminderCompletionOutcome { Uncertain, } -/// A Graph task-list or task id: non-empty, not `.`/`..`, and bounded, same -/// as the id check `create()` already applies to the resolved list id. +/// A provider task-list or task id (To Do or Google Tasks): non-empty, not +/// `.`/`..`, and bounded, same as the id check `create()` applies to the +/// resolved list id. pub(super) fn valid_remote_id(id: &str) -> bool { !id.is_empty() && id != "." && id != ".." && id.len() <= 2048 } fn task_body(request: &ReminderRequest) -> Result, ReminderFailure> { - let now: chrono::DateTime = std::time::SystemTime::now().into(); - if request.account.is_empty() - || request.title.trim().chars().count() < 3 - || request.title.chars().count() > 320 - || request.title.chars().any(char::is_control) - || request.at_utc <= now.timestamp() - || request.marker.len() != 64 - || !request.marker.bytes().all(|b| b.is_ascii_hexdigit()) - { - return Err(ReminderFailure::InvalidDraft); - } + validate_request(request)?; let when = chrono::DateTime::from_timestamp(request.at_utc, 0) .ok_or(ReminderFailure::InvalidDraft)? .format("%Y-%m-%dT%H:%M:%S") @@ -105,10 +112,39 @@ fn task_body(request: &ReminderRequest) -> Result, ReminderFailure> { serde_json::to_vec(&json!({"title":request.title,"body":{"contentType":"text","content":format!("Created after review in OpenLoops.\nOpenLoops reference: {}",request.marker)},"isReminderOn":true,"reminderDateTime":{"dateTime":when,"timeZone":"UTC"},"dueDateTime":{"dateTime":when,"timeZone":"UTC"}})).map_err(|_|ReminderFailure::InvalidDraft) } +pub(super) fn validate_request(request: &ReminderRequest) -> Result<(), ReminderFailure> { + let now: chrono::DateTime = std::time::SystemTime::now().into(); + if request.account.is_empty() + || request.title.trim().chars().count() < 3 + || request.title.chars().count() > 320 + || request.title.chars().any(char::is_control) + || request.at_utc <= now.timestamp() + || request.marker.len() != 64 + || !request.marker.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(ReminderFailure::InvalidDraft); + } + Ok(()) +} + +/// The one formatter for a reminder instant in local time with its UTC +/// offset. The draft's scheduled line and the Google Tasks notes both use it. +#[must_use] +pub fn format_local_reminder_time(at_utc: i64) -> Option { + chrono::DateTime::from_timestamp(at_utc, 0).map(|time| { + time.with_timezone(&chrono::Local) + .format("%a %b %d, %Y at %H:%M %:z") + .to_string() + }) +} + /// Creates exactly one reviewed task, with an independently authorized session. /// Caller must durably record the attempt before invoking this function. #[must_use] pub fn create(config: &ConnectionConfig, request: &ReminderRequest) -> ReminderOutcome { + if request.provider != MailProvider::Microsoft { + return ReminderOutcome::NotCreated(ReminderFailure::InvalidDraft); + } let body = match task_body(request) { Ok(b) => b, Err(e) => return ReminderOutcome::NotCreated(e), @@ -197,6 +233,7 @@ fn create_after_sign_in( .filter(|task_id| valid_remote_id(task_id)) { Some(task_id) => Ok(ReminderOutcome::Created { + provider: MailProvider::Microsoft, list_id: id.to_owned(), task_id: task_id.to_owned(), }), @@ -259,7 +296,7 @@ pub fn complete( } /// Whether a previously created task's `status` currently reads as -/// completed. Read-only: never writes anything back to Graph. A task +/// completed. Read-only: never writes anything back to the provider. A task /// deleted since creation (404), or any other failure, is `Unknown` rather /// than `NotCompleted` -- the caller must not treat "couldn't check" the /// same as "confirmed still open". @@ -349,6 +386,16 @@ mod tests { .unwrap() } + #[test] + fn local_reminder_time_carries_the_utc_offset() { + let expected = chrono::DateTime::from_timestamp(4_000_000_000, 0) + .unwrap() + .with_timezone(&chrono::Local) + .format("%a %b %d, %Y at %H:%M %:z") + .to_string(); + assert_eq!(format_local_reminder_time(4_000_000_000), Some(expected)); + } + #[test] fn reminder_payload_is_exact_reviewed_action_not_model_evidence() { let request = valid_request(); diff --git a/crates/openloops-graph/src/live/review.rs b/crates/openloops-graph/src/live/review.rs index 73c55db..86f814a 100644 --- a/crates/openloops-graph/src/live/review.rs +++ b/crates/openloops-graph/src/live/review.rs @@ -76,6 +76,43 @@ pub struct LoadProgress { pub cancel: AtomicBool, } +/// Counter values at the start of one provider's load; see [`LoadProgress::rewind`]. +pub struct LoadCheckpoint { + listed: usize, + loaded: usize, + sources_done: usize, +} + +impl LoadProgress { + /// Starts one download job over every provider: zeroes the counters and + /// sets the planned source total. Called once per job, never by a loader. + pub fn begin(&self, total_sources: usize) { + self.listed.store(0, Ordering::Relaxed); + self.loaded.store(0, Ordering::Relaxed); + self.sources_done.store(0, Ordering::Relaxed); + self.sources_total.store(total_sources, Ordering::Relaxed); + } + + /// The counters before a loader starts adding to them. + #[must_use] + pub fn checkpoint(&self) -> LoadCheckpoint { + LoadCheckpoint { + listed: self.listed.load(Ordering::Relaxed), + loaded: self.loaded.load(Ordering::Relaxed), + sources_done: self.sources_done.load(Ordering::Relaxed), + } + } + + /// Drops a loader's own increments when its session work restarts after + /// a re-authorization, keeping earlier providers' counts. + pub fn rewind(&self, checkpoint: &LoadCheckpoint) { + self.listed.store(checkpoint.listed, Ordering::Relaxed); + self.loaded.store(checkpoint.loaded, Ordering::Relaxed); + self.sources_done + .store(checkpoint.sources_done, Ordering::Relaxed); + } +} + /// Downloaded messages retained only for the current process session. pub type MailCache = HashMap<(String, String), MailItem>; @@ -89,6 +126,8 @@ pub fn load_recent(config: &ConnectionConfig) -> Result, Conne /// Browser sign-in, then a cancellable, cache-aware download of recent mail. /// +/// Call [`LoadProgress::begin`] once per job before this. Loaders never call it. +/// /// # Errors /// Returns fixed configuration/authentication errors, or [`ConnectionError::Cancelled`] when the /// caller stops the download. Source-specific failures remain visible on their source. @@ -102,6 +141,8 @@ pub fn load_recent_with( /// Loads every configured source, or only labels present in `source_filter`. /// +/// Call [`LoadProgress::begin`] once per job before this. Loaders never call it. +/// /// # Errors /// Returns fixed configuration/authentication errors, or [`ConnectionError::Cancelled`]. #[allow(clippy::too_many_lines)] @@ -117,27 +158,10 @@ pub fn load_sources_with( if progress.cancel.load(Ordering::Acquire) { return Err(ConnectionError::Cancelled); } - progress.listed.store(0, Ordering::Relaxed); - progress.loaded.store(0, Ordering::Relaxed); - progress.sources_done.store(0, Ordering::Relaxed); let selected = |label: &str| source_selected(source_filter, label); - let configured_labels = source_labels(config); - progress.sources_total.store( - configured_labels - .iter() - .filter(|label| selected(label)) - .count(), - Ordering::Relaxed, - ); + let start = progress.checkpoint(); with_session(config, |http, token, scope| { - progress.listed.store(0, Ordering::Relaxed); - progress.loaded.store(0, Ordering::Relaxed); - progress.sources_done.store(0, Ordering::Relaxed); - let active_labels = source_labels_for_scope(config, scope); - progress.sources_total.store( - active_labels.iter().filter(|label| selected(label)).count(), - Ordering::Relaxed, - ); + progress.rewind(&start); ensure_loading(progress)?; let identity = identity(http, token)?; ensure_loading(progress)?; @@ -170,19 +194,11 @@ pub fn load_sources_with( for address in &config.shared_mailboxes { ensure_loading(progress)?; if scope == SharedScope::Missing { - if !selected(address) { - continue; - } - sources.push(SourceReview { - provider: MailProvider::Microsoft, - label: address.clone(), - messages: vec![], - errors: vec![ConnectionError::MissingSharedScope], - message_errors: vec![], - partial: false, - failed: true, - }); - progress.sources_done.fetch_add(1, Ordering::Relaxed); + sources.extend(missing_shared_scope_source( + address, + source_filter, + progress, + )); } else { if selected(&format!("{address} / Inbox")) { sources.push(load_mailbox( @@ -214,6 +230,33 @@ pub fn load_sources_with( }) } +/// The failed source for a shared mailbox when the shared-mail scope is +/// missing, or `None` when the filter excludes it. Both planned folder labels +/// count as done: the total from [`source_count`] plans them separately. +fn missing_shared_scope_source( + address: &str, + source_filter: Option<&BTreeSet>, + progress: &LoadProgress, +) -> Option { + let planned = [ + format!("{address} / Inbox"), + format!("{address} / Sent Items"), + ] + .iter() + .filter(|label| source_selected(source_filter, label)) + .count(); + progress.sources_done.fetch_add(planned, Ordering::Relaxed); + source_selected(source_filter, address).then(|| SourceReview { + provider: MailProvider::Microsoft, + label: address.to_owned(), + messages: vec![], + errors: vec![ConnectionError::MissingSharedScope], + message_errors: vec![], + partial: false, + failed: true, + }) +} + fn source_selected(source_filter: Option<&BTreeSet>, label: &str) -> bool { source_filter.is_none_or(|filter| { filter.contains(label) @@ -241,22 +284,15 @@ fn source_labels(config: &ConnectionConfig) -> Vec { labels } -fn source_labels_for_scope(config: &ConnectionConfig, scope: SharedScope) -> Vec { - if scope != SharedScope::Missing { - return source_labels(config); - } - let mut labels = vec![ - "Personal mailbox / Inbox".into(), - "Personal mailbox / Sent Items".into(), - ]; - labels.extend( - config - .group_inboxes - .iter() - .map(|address| format!("Group: {address}")), - ); - labels.extend(config.shared_mailboxes.iter().cloned()); - labels +/// How many configured sources a load with `source_filter` plans to read. +pub(super) fn source_count( + config: &ConnectionConfig, + source_filter: Option<&BTreeSet>, +) -> usize { + source_labels(config) + .iter() + .filter(|label| source_selected(source_filter, label)) + .count() } fn ensure_loading(progress: &LoadProgress) -> Result<(), ConnectionError> { @@ -1173,6 +1209,65 @@ mod tests { } } + #[test] + fn begin_resets_counters_and_sets_the_total_once() { + let progress = LoadProgress::default(); + progress.listed.store(7, Ordering::Relaxed); + progress.loaded.store(6, Ordering::Relaxed); + progress.sources_done.store(2, Ordering::Relaxed); + progress.begin(5); + assert_eq!(progress.listed.load(Ordering::Relaxed), 0); + assert_eq!(progress.loaded.load(Ordering::Relaxed), 0); + assert_eq!(progress.sources_done.load(Ordering::Relaxed), 0); + assert_eq!(progress.sources_total.load(Ordering::Relaxed), 5); + } + + #[test] + fn rewind_restores_only_this_loaders_increments() { + let progress = LoadProgress::default(); + progress.begin(4); + progress.listed.store(3, Ordering::Relaxed); + progress.loaded.store(3, Ordering::Relaxed); + progress.sources_done.store(2, Ordering::Relaxed); + let start = progress.checkpoint(); + progress.listed.fetch_add(10, Ordering::Relaxed); + progress.loaded.fetch_add(4, Ordering::Relaxed); + progress.sources_done.fetch_add(1, Ordering::Relaxed); + progress.rewind(&start); + assert_eq!(progress.listed.load(Ordering::Relaxed), 3); + assert_eq!(progress.loaded.load(Ordering::Relaxed), 3); + assert_eq!(progress.sources_done.load(Ordering::Relaxed), 2); + assert_eq!(progress.sources_total.load(Ordering::Relaxed), 4); + } + + #[test] + fn missing_shared_scope_completes_every_planned_shared_source() { + let address = "shared@example.invalid"; + let config = + ConnectionConfig::new("11111111-1111-4111-8111-111111111111", Some(address)).unwrap(); + for (filter, expected_total, expect_source) in [ + (BTreeSet::from([address.to_owned()]), 2, true), + (BTreeSet::from([format!("{address} / Inbox")]), 1, false), + ] { + let progress = LoadProgress::default(); + progress.begin(source_count(&config, Some(&filter))); + let source = missing_shared_scope_source(address, Some(&filter), &progress); + assert_eq!( + progress.sources_total.load(Ordering::Relaxed), + expected_total + ); + assert_eq!( + progress.sources_done.load(Ordering::Relaxed), + progress.sources_total.load(Ordering::Relaxed) + ); + assert_eq!(source.is_some(), expect_source); + if let Some(source) = source { + assert!(source.failed); + assert_eq!(source.errors, [ConnectionError::MissingSharedScope]); + } + } + } + #[test] fn identity_parses_names_when_present() { let identity = parse_identity(&serde_json::json!({ diff --git a/crates/openloops-graph/src/live/test_support.rs b/crates/openloops-graph/src/live/test_support.rs index 739a45e..28a0fd6 100644 --- a/crates/openloops-graph/src/live/test_support.rs +++ b/crates/openloops-graph/src/live/test_support.rs @@ -137,6 +137,75 @@ pub(super) fn routed_server( (format!("http://127.0.0.1:{port}/"), calls, handle) } +/// Like [`routed_server`], but reads each request in full (head and +/// `Content-Length` body) and records it as text, so a test can assert the +/// method, path and body that were sent. +pub(super) fn recording_routed_server( + routes: Vec<(&'static str, Vec)>, +) -> ( + String, + Arc>>, + std::thread::JoinHandle<()>, +) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let requests = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + let handle = std::thread::spawn(move || { + for _ in 0..routes.len() { + let (mut stream, _) = listener.accept().unwrap(); + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let mut request = Vec::new(); + let mut chunk = [0u8; 4096]; + let body_start = loop { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + break None; + } + request.extend_from_slice(&chunk[..read]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break Some(end + 4); + } + }; + if let Some(start) = body_start { + let head = String::from_utf8_lossy(&request[..start]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + while request.len() < start + length { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + } + } + let text = String::from_utf8_lossy(&request).into_owned(); + let path = text + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_owned(); + recorded.lock().unwrap().push(text); + let response = routes + .iter() + .find(|(prefix, _)| path.starts_with(prefix)) + .map_or_else( + || { + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_vec() + }, + |(_, response)| response.clone(), + ); + let _ = stream.write_all(&response); + let _ = stream.flush(); + } + }); + (format!("http://127.0.0.1:{port}/"), requests, handle) +} + #[test] fn routed_server_selects_by_path_prefix_in_any_order() { let response = |body: &str| { diff --git a/docs/adr/ADR-009-reminder-adapters.md b/docs/adr/ADR-009-reminder-adapters.md index 9f178d3..444ac89 100644 --- a/docs/adr/ADR-009-reminder-adapters.md +++ b/docs/adr/ADR-009-reminder-adapters.md @@ -159,3 +159,22 @@ operation kinds, incomplete operation keys, runtime activation, claims, and additive documentation contradictions. Fresh privacy, protocol, and adversarial judges are required for closure; their prompts, transcripts, and output are not repository evidence. + +## Amendment (2026-10-04): Google Tasks adapter and marker placement + +The owner approved on 2026-09-27 a Google Tasks adapter for Gmail accounts alongside the +Microsoft To Do adapter. It follows this ADR's confirmation-first, single-request, +no-automatic-retry rules through the same reminder outcome types. Google Tasks records a +calendar date only for `due` and raises no alert; the application states this in the draft, +sends the user's local calendar date, and writes the chosen reminder time as text in the +task's notes. Completion for a detected loop still yields `completed_needs_evidence`. + +Marker placement. Neither Microsoft To Do nor Google Tasks offers an application-private +opaque field proven by an adapter gate. Both adapters therefore transmit the recoverable +marker inside the task's free-text body (`body.content` for To Do, `notes` for Google Tasks) +on a line of its own prefixed "OpenLoops reference:", and never in the title or due value. +Only the marker's HMAC is persisted. This records the current implementation; it does not +claim either field as a proven opaque marker field, and the correlation rules above +(complete enumeration, one match links, many or incomplete require the user) are unchanged. + +`contracts/reminder/adapter-boundary.json` is unchanged by this amendment. diff --git a/docs/plans/2026-09-27-google-provider-and-shared-registration.md b/docs/plans/2026-09-27-google-provider-and-shared-registration.md index 08a7f23..a550aaa 100644 --- a/docs/plans/2026-09-27-google-provider-and-shared-registration.md +++ b/docs/plans/2026-09-27-google-provider-and-shared-registration.md @@ -318,6 +318,18 @@ hash re-pins (two SHA-256 sites). added; ADR-001:46 amendment ("multiple accounts" no longer excluded for the two-provider case). +## Follow-ups recorded during PR-4 review (not in scope of PR-4) + +- Disabling a provider removes its messages and cards, but a surviving card of the other + provider may keep `mentions`, `resolution`, `deadline` or `event` anchors that point at + removed handles, and a survivor whose folded duplicate mention belonged to the removed + provider is dropped. Display staleness only until the next scan; lookups return `Option`. +- "Reload saved settings" can change `mail_providers` without clearing the dropped + provider's session, cache, or review data (pre-existing shape; now visible with two + providers). +- With two providers enabled, "Scan inboxes" prompts both sign-ins back to back. A later + change could let the user pick which provider to sign in first. + ## Verification Per PR, in this order, reading each result before committing (never gate and commit in diff --git a/tools/check-reminder-adapter-boundary.ps1 b/tools/check-reminder-adapter-boundary.ps1 index 985b6c7..dc15b42 100644 --- a/tools/check-reminder-adapter-boundary.ps1 +++ b/tools/check-reminder-adapter-boundary.ps1 @@ -91,7 +91,7 @@ $rt=$m.runtime_boundary;if($rt.adapter_runtime-ne$false-or$rt.logical_records_ac $adr=Text $AdrPath 'P0-REMINDER-CROSS-CONTRACT-001';$threat=Text $ThreatPath 'P0-REMINDER-CROSS-CONTRACT-001';$spec=Text $ProductSpecPath 'P0-REMINDER-INVENTORY-001';$plan=Text $ImplementationPlanPath 'P0-REMINDER-INVENTORY-001';$trace=Text $TraceabilityPath 'P0-REMINDER-INVENTORY-001' # Product spec revised by PR #9; the manifest and the other documents are unchanged. -if((NHash $adr)-ne'c63839bfae64f21dd094659badc842b1ff077a2056022d258cdea660d1b2c2f9'){Fail 'P0-REMINDER-CROSS-CONTRACT-001'};if((NHash $spec)-ne'f0428f9c8ac77a14ed645afe7a30cd4b8a3b1e16783c487100c19499b4470ca9'){Fail 'P0-REMINDER-INVENTORY-001'};if((NHash $plan)-ne'bc857151555cf9be61a0e075c40907e5b9750ac62293b9a5bddf13efe6cbe575'){Fail 'P0-REMINDER-INVENTORY-001'};if((NHash $trace)-ne'dad106cd8df478768af02ff3775b077eca92ec118bac632873f4b68b712d9ad8'){Fail 'P0-REMINDER-INVENTORY-001'} +if((NHash $adr)-ne'6f7738263ff7fc59e98d41fafffabc3d98f7a64994dbc16539cb11242b43370b'){Fail 'P0-REMINDER-CROSS-CONTRACT-001'};if((NHash $spec)-ne'f0428f9c8ac77a14ed645afe7a30cd4b8a3b1e16783c487100c19499b4470ca9'){Fail 'P0-REMINDER-INVENTORY-001'};if((NHash $plan)-ne'bc857151555cf9be61a0e075c40907e5b9750ac62293b9a5bddf13efe6cbe575'){Fail 'P0-REMINDER-INVENTORY-001'};if((NHash $trace)-ne'dad106cd8df478768af02ff3775b077eca92ec118bac632873f4b68b712d9ad8'){Fail 'P0-REMINDER-INVENTORY-001'} Has $adr @('Status:** Accepted','P0-WI-12','OWN-03, OWN-04, OWN-05, OWN-07','pending encrypted operation-ledger record','The local operation key is not remote idempotency','never automatically deletes','requests no guessed scope','one random, non-secret opaque loop handle','accepts only this disabled decision contract') 'P0-REMINDER-CROSS-CONTRACT-001' Has $threat @('Reminder adapter boundary','Durable operation first','Readable content or identifiers enter state','Remote direct edit is overwritten','Reminder controls loop lifecycle','Calendar action emails or affects another person','Review link becomes a credential','Manual draft survives a crash','Rollback, account, authorization, or version drift') 'P0-REMINDER-CROSS-CONTRACT-001' $ids=[regex]::Matches($trace,'(?m)^\| (P0-REMINDER-[A-Z-]+-001) \|')|ForEach-Object{$_.Groups[1].Value};Exact @($ids) $checks 'P0-REMINDER-INVENTORY-001';foreach($id in $checks){if(([regex]::Matches($trace,'\b'+[regex]::Escape($id)+'\b')).Count-ne 1){Fail 'P0-REMINDER-INVENTORY-001'}}