feat(webview): native OS notifications from embedded webview apps (#714) (#727)

* feat(webview_accounts): native OS notifications from embedded webviews (#714)

Forward CEF notification intercept payloads to tauri-plugin-notification,
prefixing the title with the provider label so the source of each toast is
obvious at a glance. Honour `silent` (skip toast, still record route),
`icon` (passed through to the native builder), and `tag` (used as the
dedup key, with a monotonic timestamp fallback for untagged payloads).

Record a NotificationRoute keyed by `{provider}:{account_id}:{tag_or_uuid}`
so a future click hook (UNUserNotificationCenter / notify-rust on_response)
can route the OS click back to the source account. Entries are cleared on
webview_account_close / _purge to bound map growth.

Expose webview_notification_permission_state / _request commands mapping
tauri::plugin::PermissionState onto the web API triple. Non-cef stubs
return "default" so the frontend can call the same invoke names on both
runtimes. Wire notification:allow-* capabilities so the plugin can be
invoked from the webview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(accounts): wire notification permission + click bridge (#714)

Round-trip the OS notification permission once per session on first
account open via the new invoke pair. Attach a dormant notification:click
listener that dispatches setActiveAccount and brings the main window to
front when a platform click hook starts emitting the event — contract
matches the Rust NotificationRoute shape so the emit side is a one-liner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: sync Cargo.lock to 0.52.26 after version bump

Lockfile picked up the pending 0.52.26 version bump from Cargo.toml
while building the notification feature. No dependency graph change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(notifications): add notification bypass for embedded webview apps (#679)

- Add NotificationBypassPrefs (global DND, per-account mute, bypass-when-focused)
  to WebviewAccountsState with thread-safe AtomicBool window focus tracking
- Evaluate all three bypass conditions inside forward_native_notification before
  showing OS toast; each suppression path logs at debug with [notify-bypass] prefix
- Add four new Tauri commands: webview_notification_set_dnd,
  webview_notification_mute_account, webview_notification_get_bypass_prefs,
  webview_set_focused_account
- Wire window focus tracking in setup hook via on_window_event Focused handler
- Frontend: add setAccountMuted, setGlobalDnd, getBypassPrefs, setFocusedAccount
  helpers in webviewAccountService; sync focused account on open + click
- Add NotificationsPanel settings page with Global DND toggle
- Register NotificationsPanel at /settings/notifications

Closes #679

* feat(notifications): integrate notifications feature into app

- Added Notifications page and routing to AppRoutes.
- Introduced NotificationRoutingPanel in Settings for managing notification settings.
- Updated SettingsHome to include navigation for notification routing.
- Integrated notifications reducer into the store for state management.
- Enhanced Rust backend to support notification handling from embedded webviews.

This commit lays the groundwork for a comprehensive notification system within the application.

* refactor(notifications): clean up code formatting and structure

- Simplified JSX structure in NotificationCard for better readability.
- Consolidated fetchNotifications call in NotificationCenter for cleaner syntax.
- Improved formatting in NotificationRoutingPanel and notificationsSlice for consistency.
- Enhanced Rust code readability by streamlining function signatures and logic.

These changes enhance code maintainability and readability across the notifications feature.

* refactor(webview_accounts): simplify webview_notification_set_dnd function signature

- Removed unnecessary line breaks in the webview_notification_set_dnd function for improved readability.

* feat(notifications): implement provider-level notification settings management

- Added `getNotificationSettings` and `setNotificationSettings` functions to manage notification settings for providers.
- Enhanced `NotificationRoutingPanel` to display and update settings for Gmail, Slack, Discord, and WhatsApp.
- Introduced new RPC endpoints for retrieving and updating notification settings.
- Updated database schema to store notification settings persistently.

This commit establishes a robust system for managing notification preferences, improving user control over notifications.

* refactor(notifications): improve code formatting and readability

- Enhanced formatting in NotificationRoutingPanel for better clarity.
- Streamlined function signatures in notificationService and Rust backend.
- Improved readability of assertions in tests by adjusting line breaks.

These changes contribute to a more maintainable and comprehensible codebase for the notifications feature.

* chore(vendor): bump tauri-cef to fix Slack notification permission banner

Updates the tauri-cef submodule to 55db2d6 which adds a
navigator.permissions.query shim in the CEF render process. Slack checks
this API (not just Notification.permission) to decide whether to show its
"needs your permission" banner — the shim returns "granted" for
notifications queries so the banner no longer appears.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(vendor): bump tauri-cef for cargo fmt fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(vendor): bump tauri-cef — native V8 permissions.query shim

Switches from context.eval() to a proper PermissionsQueryV8Handler so
the navigator.permissions.query fix actually runs in on_context_created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(notifications): patch navigator.permissions.query in ua_spoof.js

The V8 set_value_bykey approach in cef-helper's on_context_created does
not stick on CEF platform objects (Chromium's V8 binding layer silently
ignores property writes on native wrappers like Permissions). The init
script path via frame.execute_java_script runs in the fully-initialised
JS context where navigator.permissions IS writable, matching how
ua_spoof.js already overrides navigator.userAgent successfully.

Slack checks navigator.permissions.query({ name: 'notifications' })
before showing its "needs permission" banner — patching it here to
return "granted" removes the banner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(notifications): use Object.defineProperty to shim navigator.permissions

Two-layer fix for the Slack "needs permission to enable notifications" banner:

1. cef-helper (submodule update to 99a2686): context.eval() in
   on_context_created installs Object.defineProperty(navigator, 'permissions',
   ...) before any page JS runs.

2. ua_spoof.js: same Object.defineProperty pattern as belt-and-suspenders
   for frames that reload or trigger permission checks after on_load_end.

Simple property assignment on Blink platform objects is silently ignored;
Object.defineProperty on the navigator wrapper itself (the same mechanism
already used for navigator.userAgent) works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(notifications): address CodeRabbit review issues on PR #727

- Move raw_title out of log::info! (PII risk) — log title_chars at info,
  raw_title at debug only
- Fix permissionChecked set before async invoke in ensureNotificationPermission
  so transient failures allow retry on next account open

* fix(cef): enable webview-data-url feature for CEF placeholder URL

The CEF backend uses a data: URL as the initial webview location so CDP
can attach before the real provider URL loads. Tauri's add_child rejects
data: URLs unless the webview-data-url feature is enabled.

* fix(notifications): address remaining CodeRabbit issues on PR #727

- cdp/emulation: bump Chrome UA 124→136 to pass Slack browser check
- cdp/session: inject Page.addScriptToEvaluateOnNewDocument to stub
  Notification.permission as "granted" and silence provider banners
- notifications/mod.rs + core/all.rs: wire notifications domain into
  the controller registry (fixes unknown-method in json_rpc_e2e tests)
- notifications/schemas: add skipped bool output to ingest schema
- notifications/store: add tracing::warn on datetime parse failure
- notificationService: union return type for ingestNotification
- webviewAccountService: narrow union before accessing result.id
- NotificationCenter: drive loading/error from fetch effect; track
  allProviders separately so filter pills don't collapse on selection
- NotificationRoutingPanel: rollback optimistic update on save failure
- useSettingsNavigation: add notifications/notification-routing routes
- scripts/install.sh: remove silent dry-run exit 0 on asset failure
- scripts/setup-dev-codesign.sh: remove unconditional -legacy flag
- docs/SUMMARY.md: remove worktree path, fix macOS capitalisation,
  remove self-referential deletion note

* chore: apply prettier + cargo fmt + fix useEffect dep warning

Auto-apply formatting changes flagged by the pre-push hook:
- prettier reformatted NotificationCenter.tsx and notificationService.ts
- cargo fmt reformatted all.rs, openhuman/mod.rs, notifications/schemas.rs
- NotificationRoutingPanel: move providers array to module scope so
  useEffect dependency array is satisfied without exhaustive-deps warning

* feat(notifications): enhance notification management and permissions

- Added new commands for managing notification preferences, including setting global Do Not Disturb (DND), muting specific accounts, and retrieving current bypass preferences.
- Implemented a notification permission state handler to ensure consistent behavior across different environments.
- Updated the JavaScript shim for notification permissions to handle both Notification and PushManager states, ensuring compatibility with various providers.
- Refactored the WebviewAccountsState to include a new structure for managing notification bypass preferences, improving the overall notification handling logic.

* update agents

* fix(notifications): complete schema + navigation metadata for ingest/settings routes

- app/src/components/settings/hooks/useSettingsNavigation.ts: resolve the
  new `/settings/notifications` and `/settings/notification-routing` URLs
  to their SettingsRoute values and feed them into breadcrumbs so the new
  panels don't silently fall through to `'home'`. Addresses CodeRabbit on
  useSettingsNavigation.ts:34.
- src/openhuman/notifications/schemas.rs: add the optional `reason` output
  on `notification.ingest` (populated alongside `skipped=true` by the
  runtime) and the normalized `settings` output on `notification.settings_set`
  so schema-driven clients see the full response shape. Addresses
  CodeRabbit on schemas.rs:103 and schemas.rs:217.
- src/core/all.rs: add a `notification` namespace_description so CLI help
  covers the new controllers, plus a test assertion. Addresses CodeRabbit
  on src/core/all.rs:149.

* fix(notifications): trace DB entry, surface empty update matches, warn on bad scored_at

- Add `tracing::trace!` checkpoints around the `with_connection` DB open
  and schema migration so notification-delivery issues are reconstructible
  from logs.
- `update_triage` and `mark_read` now inspect `Connection::execute`'s
  affected-row count: log a `warn!` when the update matched zero rows
  (row deleted between ingest and scoring / client passed a stale id),
  `debug!` on the normal path.
- `scored_at` parsing no longer silently drops malformed values — log a
  `warn!` with the raw value and parse error before treating the row as
  unscored, matching the existing behavior for `received_at`.

Addresses CodeRabbit on store.rs (lines 72, 172, 294).

* fix(webview): respect silent notifications, multi-host CDP fallback, shim idempotency

- webview_accounts/mod.rs: honor the Web Notification `silent` flag.
  Previously we only logged it and still called `builder.show()`, so
  pages that marked a notification silent still produced an OS toast.
  Mirror event still fires so the in-app center updates; only the OS
  toast is suppressed. Also picks up a prior cargo-fmt rewrap.
- cdp/target.rs: `browser_ws_url()` now continues the host loop when
  `resp.json()` fails instead of early-returning via `?`. A malformed
  response from the first host (CDP_HOST) no longer prevents the
  `localhost` fallback from being tried.
- webview_accounts/ua_spoof.js: guard the Notification wrapper behind
  `window.__OH_NOTIF_SHIM` so repeated evaluations of the script
  (Page.addScriptToEvaluateOnNewDocument + frame-level re-injections)
  don't stack wrappers onto the same page globals or re-proxy
  `Function.prototype.toString`.

Addresses CodeRabbit on webview_accounts/mod.rs:377, cdp/target.rs:33,
and ua_spoof.js:176.

* update agents

* update

---------

Co-authored-by: oxoxDev <nikhil@tinyhumans.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-04-22 14:13:35 -07:00
committed by GitHub
co-authored by oxoxDev Claude Opus 4.6 Steven Enamakel
parent 340cbc04f2
commit 3bb714bf96
38 changed files with 3105 additions and 424 deletions
+9
View File
@@ -147,6 +147,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(
crate::openhuman::webview_notifications::all_webview_notifications_registered_controllers(),
);
// Integration notification ingest, triage, and per-provider settings
controllers.extend(crate::openhuman::notifications::all_notifications_registered_controllers());
controllers
}
@@ -200,6 +202,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(
crate::openhuman::webview_notifications::all_webview_notifications_controller_schemas(),
);
// Integration notification ingest, triage, and per-provider settings
schemas.extend(crate::openhuman::notifications::all_notifications_controller_schemas());
schemas
}
@@ -266,6 +270,10 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"learning" => Some(
"User context enrichment — LinkedIn profile scraping and onboarding intelligence.",
),
"notification" => Some(
"Integration notification ingest, triage scoring, listing, read-state, \
and per-provider routing settings.",
),
_ => None,
}
}
@@ -535,6 +543,7 @@ mod tests {
assert!(namespace_description("health").is_some());
assert!(namespace_description("voice").is_some());
assert!(namespace_description("webhooks").is_some());
assert!(namespace_description("notification").is_some());
}
#[test]
+10
View File
@@ -19,6 +19,14 @@ pub enum TriggerSource {
/// socket.io bridge. `toolkit` is the slug like `"gmail"`;
/// `trigger` is the slug like `"GMAIL_NEW_GMAIL_MESSAGE"`.
Composio { toolkit: String, trigger: String },
/// A notification captured from an embedded webview integration
/// (WhatsApp Web, Gmail, Slack, …) via the recipe event pipeline.
/// `provider` is the slug like `"gmail"`; `account_id` is the
/// webview account identifier.
WebviewIntegration {
provider: String,
account_id: String,
},
// Cron / Webhook / … variants will be added in later commits as
// those callers wire up the triage pipeline.
}
@@ -29,6 +37,7 @@ impl TriggerSource {
pub fn slug(&self) -> &'static str {
match self {
Self::Composio { .. } => "composio",
Self::WebviewIntegration { .. } => "webview",
}
}
}
@@ -123,6 +132,7 @@ mod tests {
assert_eq!(toolkit, "gmail");
assert_eq!(trigger, "GMAIL_NEW_GMAIL_MESSAGE");
}
_ => panic!("expected Composio source"),
}
assert_eq!(env.payload["from"], "a@b.com");
}
+1
View File
@@ -40,6 +40,7 @@ pub mod local_ai;
pub mod memory;
pub mod migration;
pub mod node_runtime;
pub mod notifications;
pub mod overlay;
pub mod providers;
pub mod referral;
+23
View File
@@ -0,0 +1,23 @@
//! Integration notification domain.
//!
//! Captures notifications from embedded webview integrations (WhatsApp Web,
//! Gmail, Slack, …), runs them through the triage LLM pipeline, and stores
//! them in a unified notification center accessible via the RPC surface.
//!
//! ## Module layout
//!
//! - [`types`] — `IntegrationNotification`, `NotificationStatus`, request/response types
//! - [`store`] — SQLite persistence (one DB per workspace)
//! - [`rpc`] — Async RPC handler functions: ingest, list, mark_read
//! - [`schemas`] — Controller schema definitions and registered handler wrappers
pub mod rpc;
pub mod schemas;
pub mod store;
pub mod types;
pub use schemas::{
all_controller_schemas as all_notifications_controller_schemas,
all_registered_controllers as all_notifications_registered_controllers,
};
pub use types::*;
+256
View File
@@ -0,0 +1,256 @@
//! JSON-RPC handler functions for the notifications domain.
//!
//! Three endpoints:
//! - `notification_ingest` — write a new notification, kick off background triage
//! - `notifications_list` — paginated query with optional provider / min-score filters
//! - `notification_mark_read`— mark a single notification as read
use chrono::Utc;
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::openhuman::agent::triage::{apply_decision, run_triage, TriggerEnvelope, TriggerSource};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
use super::store;
use super::types::{
IntegrationNotification, NotificationIngestRequest, NotificationSettings,
NotificationSettingsUpsertRequest, NotificationStatus,
};
// ─────────────────────────────────────────────────────────────────────────────
// notification_ingest
// ─────────────────────────────────────────────────────────────────────────────
/// Ingest a new notification from an embedded webview integration.
///
/// Writes the record immediately, returns the new `id`, then spawns a
/// background task to run the triage pipeline and back-fill the score.
pub async fn handle_ingest(params: Map<String, Value>) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let req: NotificationIngestRequest = serde_json::from_value(Value::Object(params.clone()))
.map_err(|e| format!("[notifications::rpc] invalid ingest params: {e}"))?;
let provider_settings = store::get_settings(&config, &req.provider)
.map_err(|e| format!("[notifications::rpc] get_settings failed: {e}"))?;
if !provider_settings.enabled {
let outcome = RpcOutcome::new(
json!({ "skipped": true, "reason": "provider_disabled" }),
vec![],
);
return outcome.into_cli_compatible_json();
}
let id = Uuid::new_v4().to_string();
let notification = IntegrationNotification {
id: id.clone(),
provider: req.provider.clone(),
account_id: req.account_id.clone(),
title: req.title.clone(),
body: req.body.clone(),
raw_payload: req.raw_payload.clone(),
importance_score: None,
triage_action: None,
triage_reason: None,
status: NotificationStatus::Unread,
received_at: Utc::now(),
scored_at: None,
};
store::insert(&config, &notification)
.map_err(|e| format!("[notifications::rpc] insert failed: {e}"))?;
tracing::debug!(
id = %id,
provider = %req.provider,
"[notifications::rpc] ingested notification, spawning triage"
);
// Spawn background triage — the ingest RPC returns immediately.
let id_for_triage = id.clone();
let config_for_triage = config.clone();
tokio::spawn(async move {
let envelope = TriggerEnvelope {
source: TriggerSource::WebviewIntegration {
provider: req.provider.clone(),
account_id: req.account_id.clone().unwrap_or_default(),
},
external_id: id_for_triage.clone(),
display_label: format!(
"webview/{}/{}",
req.provider,
req.account_id.as_deref().unwrap_or("default")
),
payload: serde_json::json!({
"title": req.title,
"body": req.body,
"raw": req.raw_payload,
}),
received_at: Utc::now(),
};
match run_triage(&envelope).await {
Ok(triage_run) => {
let action = triage_run.decision.action.as_str().to_string();
let reason = triage_run.decision.reason.clone();
// Map TriageAction → importance score heuristic.
let score = triage_action_to_score(triage_run.decision.action);
tracing::info!(
id = %id_for_triage,
action = %action,
score = score,
"[notifications::rpc] triage complete"
);
if let Err(e) = store::update_triage(
&config_for_triage,
&id_for_triage,
score,
&action,
&reason,
) {
tracing::warn!(
id = %id_for_triage,
error = %e,
"[notifications::rpc] failed to persist triage result"
);
}
// Auto-escalate high-importance notifications to the orchestrator.
if (action == "escalate" || action == "react")
&& score >= provider_settings.importance_threshold
&& provider_settings.route_to_orchestrator
{
if let Err(e) = apply_decision(triage_run, &envelope).await {
tracing::warn!(
id = %id_for_triage,
error = %e,
"[notifications::rpc] apply_decision failed"
);
}
}
}
Err(e) => {
tracing::warn!(
id = %id_for_triage,
error = %e,
"[notifications::rpc] triage pipeline failed"
);
}
}
});
let outcome = RpcOutcome::new(json!({ "id": id, "skipped": false }), vec![]);
outcome.into_cli_compatible_json()
}
// ─────────────────────────────────────────────────────────────────────────────
// notifications_list
// ─────────────────────────────────────────────────────────────────────────────
/// Return paginated notifications.
///
/// Optional params: `provider` (string), `limit` (u64), `offset` (u64),
/// `min_score` (f64).
pub async fn handle_list(params: Map<String, Value>) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let provider = params
.get("provider")
.and_then(|v| v.as_str())
.map(str::to_string);
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.unwrap_or(50);
let offset = params
.get("offset")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.unwrap_or(0);
let min_score = params
.get("min_score")
.and_then(|v| v.as_f64())
.map(|v| v as f32);
let items = store::list(&config, limit, offset, provider.as_deref(), min_score)
.map_err(|e| format!("[notifications::rpc] list failed: {e}"))?;
let unread = store::unread_count(&config)
.map_err(|e| format!("[notifications::rpc] unread_count failed: {e}"))?;
let outcome = RpcOutcome::new(json!({ "items": items, "unread_count": unread }), vec![]);
outcome.into_cli_compatible_json()
}
// ─────────────────────────────────────────────────────────────────────────────
// notification_mark_read
// ─────────────────────────────────────────────────────────────────────────────
/// Mark a single notification as read.
pub async fn handle_mark_read(params: Map<String, Value>) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| "[notifications::rpc] missing required param 'id'".to_string())?
.to_string();
store::mark_read(&config, &id)
.map_err(|e| format!("[notifications::rpc] mark_read failed: {e}"))?;
tracing::debug!(id = %id, "[notifications::rpc] marked read");
let outcome = RpcOutcome::new(json!({ "ok": true }), vec![]);
outcome.into_cli_compatible_json()
}
/// Read notification routing settings for a provider.
pub async fn handle_settings_get(params: Map<String, Value>) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let provider = params
.get("provider")
.and_then(|v| v.as_str())
.ok_or_else(|| "[notifications::rpc] missing required param 'provider'".to_string())?;
let settings = store::get_settings(&config, provider)
.map_err(|e| format!("[notifications::rpc] settings_get failed: {e}"))?;
let outcome = RpcOutcome::new(json!({ "settings": settings }), vec![]);
outcome.into_cli_compatible_json()
}
/// Upsert notification routing settings for a provider.
pub async fn handle_settings_set(params: Map<String, Value>) -> Result<Value, String> {
let config = config_rpc::load_config_with_timeout().await?;
let req: NotificationSettingsUpsertRequest = serde_json::from_value(Value::Object(params))
.map_err(|e| format!("[notifications::rpc] invalid settings_set params: {e}"))?;
let clamped = NotificationSettings {
provider: req.provider,
enabled: req.enabled,
importance_threshold: req.importance_threshold.clamp(0.0, 1.0),
route_to_orchestrator: req.route_to_orchestrator,
};
store::upsert_settings(&config, &clamped)
.map_err(|e| format!("[notifications::rpc] settings_set failed: {e}"))?;
let outcome = RpcOutcome::new(json!({ "ok": true, "settings": clamped }), vec![]);
outcome.into_cli_compatible_json()
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Map the triage decision to a 0.01.0 importance score so the frontend
/// can sort/filter without understanding triage action semantics.
fn triage_action_to_score(action: crate::openhuman::agent::triage::TriageAction) -> f32 {
use crate::openhuman::agent::triage::TriageAction;
match action {
TriageAction::Drop => 0.1,
TriageAction::Acknowledge => 0.35,
TriageAction::React => 0.65,
TriageAction::Escalate => 0.9,
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Controller schema definitions and registered handlers for the
//! `notifications` domain.
//!
//! Follows the exact pattern from `src/openhuman/cron/schemas.rs`.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
// ─────────────────────────────────────────────────────────────────────────────
// Schema registry
// ─────────────────────────────────────────────────────────────────────────────
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("ingest"),
schemas("list"),
schemas("mark_read"),
schemas("settings_get"),
schemas("settings_set"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("ingest"),
handler: handle_ingest_wrap,
},
RegisteredController {
schema: schemas("list"),
handler: handle_list_wrap,
},
RegisteredController {
schema: schemas("mark_read"),
handler: handle_mark_read_wrap,
},
RegisteredController {
schema: schemas("settings_get"),
handler: handle_settings_get_wrap,
},
RegisteredController {
schema: schemas("settings_set"),
handler: handle_settings_set_wrap,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"ingest" => ControllerSchema {
namespace: "notification",
function: "ingest",
description: "Ingest a new notification from an embedded webview integration. \
Immediately persists the record and kicks off background triage scoring.",
inputs: vec![
FieldSchema {
name: "provider",
ty: TypeSchema::String,
comment: "Provider slug, e.g. \"gmail\", \"slack\", \"whatsapp\".",
required: true,
},
FieldSchema {
name: "account_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Webview account identifier (optional).",
required: false,
},
FieldSchema {
name: "title",
ty: TypeSchema::String,
comment: "Short notification title / subject.",
required: true,
},
FieldSchema {
name: "body",
ty: TypeSchema::String,
comment: "Notification body or preview text.",
required: true,
},
FieldSchema {
name: "raw_payload",
ty: TypeSchema::Ref("JsonObject"),
comment: "Full raw event payload from the source for downstream use.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "UUID of the newly created notification record. Absent when skipped.",
required: false,
},
FieldSchema {
name: "skipped",
ty: TypeSchema::Bool,
comment:
"True when the provider is disabled and the notification was not stored.",
required: true,
},
FieldSchema {
name: "reason",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Human-readable reason populated alongside `skipped=true` \
(e.g. \"provider_disabled\").",
required: false,
},
],
},
"list" => ControllerSchema {
namespace: "notification",
function: "list",
description: "Return a paginated list of ingested notifications with optional \
provider and minimum-importance-score filters.",
inputs: vec![
FieldSchema {
name: "provider",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Filter by provider slug. Omit to return all providers.",
required: false,
},
FieldSchema {
name: "limit",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Maximum number of records to return; defaults to 50.",
required: false,
},
FieldSchema {
name: "offset",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Number of records to skip for pagination; defaults to 0.",
required: false,
},
FieldSchema {
name: "min_score",
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
comment: "Minimum importance score 0.01.0. Unscored items pass through.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "items",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("IntegrationNotification"))),
comment: "Notification records ordered by received_at descending.",
required: true,
},
FieldSchema {
name: "unread_count",
ty: TypeSchema::I64,
comment: "Total count of unread notifications across all providers.",
required: true,
},
],
},
"mark_read" => ControllerSchema {
namespace: "notification",
function: "mark_read",
description: "Mark a single notification as read by its id.",
inputs: vec![FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "UUID of the notification to mark as read.",
required: true,
}],
outputs: vec![FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the update succeeded.",
required: true,
}],
},
"settings_get" => ControllerSchema {
namespace: "notification",
function: "settings_get",
description: "Get provider-level notification routing settings.",
inputs: vec![FieldSchema {
name: "provider",
ty: TypeSchema::String,
comment: "Provider slug, e.g. \"gmail\".",
required: true,
}],
outputs: vec![FieldSchema {
name: "settings",
ty: TypeSchema::Ref("NotificationSettings"),
comment: "Current settings for provider, defaulted if missing.",
required: true,
}],
},
"settings_set" => ControllerSchema {
namespace: "notification",
function: "settings_set",
description: "Upsert provider-level notification routing settings.",
inputs: vec![
FieldSchema {
name: "provider",
ty: TypeSchema::String,
comment: "Provider slug, e.g. \"gmail\".",
required: true,
},
FieldSchema {
name: "enabled",
ty: TypeSchema::Bool,
comment: "Enable/disable ingestion for this provider.",
required: true,
},
FieldSchema {
name: "importance_threshold",
ty: TypeSchema::F64,
comment: "Minimum score 0.0..1.0 for routing decisions.",
required: true,
},
FieldSchema {
name: "route_to_orchestrator",
ty: TypeSchema::Bool,
comment: "When true, allow triage react/escalate to route to orchestrator.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when settings were saved.",
required: true,
},
FieldSchema {
name: "settings",
ty: TypeSchema::Ref("NotificationSettings"),
comment: "The normalized (clamped) settings that were persisted.",
required: true,
},
],
},
_other => ControllerSchema {
namespace: "notification",
function: "unknown",
description: "Unknown notification controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Handler wrappers (delegate to rpc.rs)
// ─────────────────────────────────────────────────────────────────────────────
fn handle_ingest_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_ingest(params).await })
}
fn handle_list_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_list(params).await })
}
fn handle_mark_read_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_mark_read(params).await })
}
fn handle_settings_get_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_settings_get(params).await })
}
fn handle_settings_set_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_settings_set(params).await })
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_controller_schemas_covers_three_functions() {
let names: Vec<_> = all_controller_schemas()
.into_iter()
.map(|s| s.function)
.collect();
assert_eq!(
names,
vec![
"ingest",
"list",
"mark_read",
"settings_get",
"settings_set"
]
);
}
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 5);
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
assert_eq!(
names,
vec![
"ingest",
"list",
"mark_read",
"settings_get",
"settings_set"
]
);
}
#[test]
fn schemas_ingest_requires_provider_title_body_raw_payload() {
let s = schemas("ingest");
assert_eq!(s.namespace, "notification");
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert!(required.contains(&"provider"));
assert!(required.contains(&"title"));
assert!(required.contains(&"body"));
assert!(required.contains(&"raw_payload"));
}
#[test]
fn schemas_list_all_inputs_optional() {
let s = schemas("list");
assert!(s.inputs.iter().all(|f| !f.required));
}
#[test]
fn schemas_mark_read_requires_id() {
let s = schemas("mark_read");
assert_eq!(s.inputs.len(), 1);
assert_eq!(s.inputs[0].name, "id");
assert!(s.inputs[0].required);
}
#[test]
fn schemas_unknown_returns_placeholder() {
let s = schemas("does-not-exist");
assert_eq!(s.function, "unknown");
}
}
+464
View File
@@ -0,0 +1,464 @@
//! SQLite persistence for `IntegrationNotification` records.
//!
//! Uses a synchronous `rusqlite::Connection` opened per call, following the
//! same `with_connection` pattern as the cron domain.
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use crate::openhuman::config::Config;
use super::types::{IntegrationNotification, NotificationSettings, NotificationStatus};
/// SQL schema applied on every `with_connection` call (idempotent).
const SCHEMA: &str = "
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS integration_notifications (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
account_id TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL,
raw_payload TEXT NOT NULL,
importance_score REAL,
triage_action TEXT,
triage_reason TEXT,
status TEXT NOT NULL DEFAULT 'unread',
received_at TEXT NOT NULL,
scored_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_integration_notifications_provider
ON integration_notifications(provider);
CREATE INDEX IF NOT EXISTS idx_integration_notifications_status
ON integration_notifications(status);
CREATE TABLE IF NOT EXISTS notification_settings (
provider TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
importance_threshold REAL NOT NULL DEFAULT 0.0,
route_to_orchestrator INTEGER NOT NULL DEFAULT 1
);
";
/// Open (and migrate) the notifications DB, then call `f` with the live
/// connection. Mirrors the `with_connection` helper in `cron/store.rs`.
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let db_path = config
.workspace_dir
.join("notifications")
.join("notifications.db");
tracing::trace!(
path = %db_path.display(),
"[notifications::store] opening DB connection"
);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"[notifications::store] failed to create dir {}",
parent.display()
)
})?;
}
let conn = Connection::open(&db_path).with_context(|| {
format!(
"[notifications::store] failed to open DB at {}",
db_path.display()
)
})?;
conn.execute_batch(SCHEMA)
.context("[notifications::store] schema migration failed")?;
tracing::trace!("[notifications::store] schema migration applied, running operation");
f(&conn)
}
// ─────────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────────
/// Persist a new notification to the store.
pub fn insert(config: &Config, n: &IntegrationNotification) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"INSERT INTO integration_notifications
(id, provider, account_id, title, body, raw_payload,
importance_score, triage_action, triage_reason, status,
received_at, scored_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
n.id,
n.provider,
n.account_id,
n.title,
n.body,
n.raw_payload.to_string(),
n.importance_score,
n.triage_action,
n.triage_reason,
n.status.as_str(),
n.received_at.to_rfc3339(),
n.scored_at.map(|t| t.to_rfc3339()),
],
)
.context("[notifications::store] insert failed")?;
Ok(())
})
}
/// List notifications with optional filtering.
pub fn list(
config: &Config,
limit: usize,
offset: usize,
provider_filter: Option<&str>,
min_score: Option<f32>,
) -> Result<Vec<IntegrationNotification>> {
with_connection(config, |conn| {
// Build a dynamic query instead of relying on nullable-aware WHERE
// logic so the SQL stays readable for future contributors.
let mut sql = String::from(
"SELECT id, provider, account_id, title, body, raw_payload,
importance_score, triage_action, triage_reason, status,
received_at, scored_at
FROM integration_notifications
WHERE 1=1",
);
if provider_filter.is_some() {
sql.push_str(" AND provider = ?1");
}
if min_score.is_some() {
if provider_filter.is_some() {
sql.push_str(" AND (importance_score IS NULL OR importance_score >= ?2)");
} else {
sql.push_str(" AND (importance_score IS NULL OR importance_score >= ?1)");
}
}
sql.push_str(" ORDER BY received_at DESC");
sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
let mut stmt = conn
.prepare(&sql)
.context("[notifications::store] prepare list failed")?;
let rows = match (provider_filter, min_score) {
(Some(p), Some(s)) => stmt.query(params![p, s]),
(Some(p), None) => stmt.query(params![p]),
(None, Some(s)) => stmt.query(params![s]),
(None, None) => stmt.query([]),
}
.context("[notifications::store] list query failed")?;
rows_to_notifications(rows)
})
}
/// Update triage scoring fields in-place.
pub fn update_triage(
config: &Config,
id: &str,
score: f32,
action: &str,
reason: &str,
) -> Result<()> {
with_connection(config, |conn| {
let now = Utc::now().to_rfc3339();
let updated = conn
.execute(
"UPDATE integration_notifications
SET importance_score = ?1, triage_action = ?2, triage_reason = ?3, scored_at = ?4
WHERE id = ?5",
params![score, action, reason, now, id],
)
.context("[notifications::store] update_triage failed")?;
if updated == 0 {
// The row may have been deleted between ingest and scoring.
// Surface it at warn level so orphaned triage runs don't fail
// silently.
tracing::warn!(
id = %id,
action = %action,
"[notifications::store] update_triage matched no rows"
);
} else {
tracing::debug!(
id = %id,
action = %action,
score = score,
"[notifications::store] update_triage applied"
);
}
Ok(())
})
}
/// Transition a notification from `unread` to `read`.
pub fn mark_read(config: &Config, id: &str) -> Result<()> {
with_connection(config, |conn| {
let updated = conn
.execute(
"UPDATE integration_notifications SET status = 'read' WHERE id = ?1",
params![id],
)
.context("[notifications::store] mark_read failed")?;
if updated == 0 {
tracing::warn!(
id = %id,
"[notifications::store] mark_read matched no rows"
);
} else {
tracing::debug!(id = %id, "[notifications::store] mark_read applied");
}
Ok(())
})
}
/// Count unread notifications.
pub fn unread_count(config: &Config) -> Result<i64> {
with_connection(config, |conn| {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM integration_notifications WHERE status = 'unread'",
[],
|row| row.get(0),
)
.context("[notifications::store] unread_count failed")?;
Ok(count)
})
}
/// Upsert provider-level notification settings.
pub fn upsert_settings(config: &Config, settings: &NotificationSettings) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"INSERT INTO notification_settings (provider, enabled, importance_threshold, route_to_orchestrator)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(provider) DO UPDATE SET
enabled = excluded.enabled,
importance_threshold = excluded.importance_threshold,
route_to_orchestrator = excluded.route_to_orchestrator",
params![
settings.provider,
if settings.enabled { 1 } else { 0 },
settings.importance_threshold,
if settings.route_to_orchestrator { 1 } else { 0 }
],
)
.context("[notifications::store] upsert_settings failed")?;
Ok(())
})
}
/// Read provider-level notification settings with defaults when missing.
pub fn get_settings(config: &Config, provider: &str) -> Result<NotificationSettings> {
with_connection(config, |conn| {
let mut stmt = conn
.prepare(
"SELECT provider, enabled, importance_threshold, route_to_orchestrator
FROM notification_settings
WHERE provider = ?1",
)
.context("[notifications::store] prepare get_settings failed")?;
let mut rows = stmt
.query(params![provider])
.context("[notifications::store] get_settings query failed")?;
if let Some(row) = rows
.next()
.context("[notifications::store] get_settings row failed")?
{
return Ok(NotificationSettings {
provider: row.get(0)?,
enabled: row.get::<_, i64>(1)? != 0,
importance_threshold: row.get(2)?,
route_to_orchestrator: row.get::<_, i64>(3)? != 0,
});
}
Ok(NotificationSettings {
provider: provider.to_string(),
..NotificationSettings::default()
})
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Row conversion helpers
// ─────────────────────────────────────────────────────────────────────────────
fn rows_to_notifications(mut rows: rusqlite::Rows<'_>) -> Result<Vec<IntegrationNotification>> {
let mut out = Vec::new();
while let Some(row) = rows
.next()
.context("[notifications::store] row iteration failed")?
{
out.push(row_to_notification(row)?);
}
Ok(out)
}
fn row_to_notification(row: &rusqlite::Row<'_>) -> Result<IntegrationNotification> {
let raw_payload_str: String = row.get(5)?;
let raw_payload: serde_json::Value = serde_json::from_str(&raw_payload_str)
.unwrap_or(serde_json::Value::String(raw_payload_str));
let status_str: String = row.get(9)?;
let status = match status_str.as_str() {
"read" => NotificationStatus::Read,
"acted" => NotificationStatus::Acted,
"dismissed" => NotificationStatus::Dismissed,
_ => NotificationStatus::Unread,
};
let received_at_str: String = row.get(10)?;
let received_at: DateTime<Utc> = received_at_str.parse().unwrap_or_else(|e| {
tracing::warn!(
raw = %received_at_str,
error = %e,
"[notifications::store] invalid received_at, using now"
);
Utc::now()
});
let scored_at_str: Option<String> = row.get(11)?;
let scored_at: Option<DateTime<Utc>> = scored_at_str.and_then(|s| match s.parse() {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(
raw = %s,
error = %e,
"[notifications::store] invalid scored_at, treating as unscored"
);
None
}
});
Ok(IntegrationNotification {
id: row.get(0)?,
provider: row.get(1)?,
account_id: row.get(2)?,
title: row.get(3)?,
body: row.get(4)?,
raw_payload,
importance_score: row.get(6)?,
triage_action: row.get(7)?,
triage_reason: row.get(8)?,
status,
received_at,
scored_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::config::Config;
use tempfile::TempDir;
fn test_config(dir: &TempDir) -> Config {
let mut config = Config::default();
config.workspace_dir = dir.path().to_path_buf();
config
}
fn sample_notification(id: &str, provider: &str) -> IntegrationNotification {
IntegrationNotification {
id: id.to_string(),
provider: provider.to_string(),
account_id: None,
title: "Test notification".to_string(),
body: "Test body".to_string(),
raw_payload: serde_json::json!({"test": true}),
importance_score: None,
triage_action: None,
triage_reason: None,
status: NotificationStatus::Unread,
received_at: Utc::now(),
scored_at: None,
}
}
#[test]
fn insert_and_list_roundtrip() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let n = sample_notification("n1", "gmail");
insert(&config, &n).unwrap();
let items = list(&config, 10, 0, None, None).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "n1");
assert_eq!(items[0].provider, "gmail");
}
#[test]
fn unread_count_increments_on_insert_and_decrements_on_read() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
assert_eq!(unread_count(&config).unwrap(), 0);
insert(&config, &sample_notification("a", "slack")).unwrap();
insert(&config, &sample_notification("b", "slack")).unwrap();
assert_eq!(unread_count(&config).unwrap(), 2);
mark_read(&config, "a").unwrap();
assert_eq!(unread_count(&config).unwrap(), 1);
}
#[test]
fn update_triage_fills_scoring_fields() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
insert(&config, &sample_notification("t1", "gmail")).unwrap();
update_triage(&config, "t1", 0.9, "escalate", "important email").unwrap();
let items = list(&config, 10, 0, None, None).unwrap();
assert_eq!(items[0].importance_score, Some(0.9));
assert_eq!(items[0].triage_action.as_deref(), Some("escalate"));
assert_eq!(items[0].triage_reason.as_deref(), Some("important email"));
assert!(items[0].scored_at.is_some());
}
#[test]
fn provider_filter_works() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
insert(&config, &sample_notification("g1", "gmail")).unwrap();
insert(&config, &sample_notification("s1", "slack")).unwrap();
let gmail = list(&config, 10, 0, Some("gmail"), None).unwrap();
assert_eq!(gmail.len(), 1);
assert_eq!(gmail[0].provider, "gmail");
}
#[test]
fn settings_roundtrip_defaults_and_upsert() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let defaults = get_settings(&config, "gmail").unwrap();
assert_eq!(defaults.provider, "gmail");
assert!(defaults.enabled);
assert_eq!(defaults.importance_threshold, 0.0);
assert!(defaults.route_to_orchestrator);
upsert_settings(
&config,
&NotificationSettings {
provider: "gmail".to_string(),
enabled: false,
importance_threshold: 0.75,
route_to_orchestrator: false,
},
)
.unwrap();
let updated = get_settings(&config, "gmail").unwrap();
assert!(!updated.enabled);
assert_eq!(updated.importance_threshold, 0.75);
assert!(!updated.route_to_orchestrator);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Core types for the integration notification domain.
//!
//! `IntegrationNotification` is the central record that flows from webview
//! recipe events → triage pipeline → notification center UI.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Lifecycle state for an ingested notification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotificationStatus {
#[default]
Unread,
Read,
Acted,
Dismissed,
}
impl NotificationStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Unread => "unread",
Self::Read => "read",
Self::Acted => "acted",
Self::Dismissed => "dismissed",
}
}
}
/// A single notification captured from an embedded webview integration.
///
/// Notifications are written on ingest and enriched in-place once the
/// triage pipeline produces its score/action. The `importance_score`,
/// `triage_action`, and `triage_reason` fields are `None` until the
/// background triage task completes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationNotification {
pub id: String,
/// Provider slug: `"gmail"`, `"slack"`, `"whatsapp"`, etc.
pub provider: String,
/// Webview account id if the notification came from an embedded account.
pub account_id: Option<String>,
/// Short subject / title text.
pub title: String,
/// Body / preview text.
pub body: String,
/// Full raw event payload from the recipe for downstream use.
pub raw_payload: serde_json::Value,
/// 0.01.0 importance score produced by the triage pipeline (optional).
pub importance_score: Option<f32>,
/// Triage action string: `"drop"` / `"acknowledge"` / `"react"` / `"escalate"`.
pub triage_action: Option<String>,
/// One-sentence justification from the classifier.
pub triage_reason: Option<String>,
/// Lifecycle status.
pub status: NotificationStatus,
/// Wall-clock time the notification arrived.
pub received_at: DateTime<Utc>,
/// Wall-clock time triage completed.
pub scored_at: Option<DateTime<Utc>>,
}
/// Per-provider user preference controlling which notifications surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettings {
pub provider: String,
/// Whether notifications from this provider should be ingested at all.
pub enabled: bool,
/// Minimum importance score (0.01.0) to display; 0.0 = show all.
pub importance_threshold: f32,
/// When `true`, triage-escalated notifications are also auto-forwarded to
/// the orchestrator agent.
pub route_to_orchestrator: bool,
}
impl Default for NotificationSettings {
fn default() -> Self {
Self {
provider: String::new(),
enabled: true,
importance_threshold: 0.0,
route_to_orchestrator: true,
}
}
}
/// Payload for the `notification_ingest` RPC endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationIngestRequest {
/// Provider slug: `"gmail"`, `"slack"`, etc.
pub provider: String,
/// Webview account id (optional).
pub account_id: Option<String>,
/// Human-readable notification title.
pub title: String,
/// Notification body / preview.
pub body: String,
/// Full raw payload from the source.
pub raw_payload: serde_json::Value,
}
/// Payload for `notification_settings_set`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettingsUpsertRequest {
pub provider: String,
pub enabled: bool,
pub importance_threshold: f32,
pub route_to_orchestrator: bool,
}