//! Window management commands. //! //! Handles creating and managing Tauri windows such as the compose email window, //! and dynamic window title updates. These are desktop-only operations. use tracing::instrument; use super::{ApiError, ResultApiError}; /// Percent-encode a string for use in URL query parameters. fn url_encode(s: &str) -> String { let mut encoded = String::with_capacity(s.len() * 2); for byte in s.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { encoded.push(byte as char); } _ => { encoded.push_str(&format!("%{:02X}", byte)); } } } encoded } // ============ Commands ============ /// Optional reply context passed when opening compose for a reply. #[derive(Debug, serde::Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct ComposeContext { pub to: Option, pub subject: Option, pub body: Option, pub in_reply_to: Option, pub references: Option, pub thread_id: Option, pub account_id: Option, pub draft_id: Option, } #[tauri::command] #[instrument(skip_all)] pub async fn open_compose_window( #[allow(unused_variables)] app: tauri::AppHandle, #[allow(unused_variables)] context: Option, ) -> Result<(), ApiError> { #[cfg(not(any(target_os = "ios", target_os = "android")))] { use tauri::{WebviewWindowBuilder, WebviewUrl}; let label = format!("compose-{}", chrono::Utc::now().timestamp_millis()); // Build URL with reply context as query params let mut url_str = "compose.html".to_string(); if let Some(ctx) = &context { let mut params = Vec::new(); if let Some(to) = &ctx.to { params.push(format!("to={}", url_encode(to))); } if let Some(subject) = &ctx.subject { params.push(format!("subject={}", url_encode(subject))); } if let Some(body) = &ctx.body { params.push(format!("body={}", url_encode(body))); } if let Some(irt) = &ctx.in_reply_to { params.push(format!("inReplyTo={}", url_encode(irt))); } if let Some(refs) = &ctx.references { params.push(format!("references={}", url_encode(refs))); } if let Some(tid) = &ctx.thread_id { params.push(format!("threadId={}", url_encode(tid))); } if let Some(aid) = &ctx.account_id { params.push(format!("accountId={}", url_encode(aid))); } if let Some(did) = &ctx.draft_id { params.push(format!("draftId={}", url_encode(did))); } if !params.is_empty() { url_str = format!("compose.html?{}", params.join("&")); } } let title = if context.as_ref().and_then(|c| c.draft_id.as_ref()).is_some() { "Edit Draft" } else if context.as_ref().and_then(|c| c.in_reply_to.as_ref()).is_some() { "Reply" } else { "Compose Email" }; WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url_str.into())) .title(title) .inner_size(700.0, 550.0) .min_inner_size(500.0, 400.0) .resizable(true) .center() .build() .map_api_err("Failed to create compose window", ApiError::internal)?; } Ok(()) } #[tauri::command] #[instrument(skip_all)] pub async fn set_window_title( #[allow(unused_variables)] app: tauri::AppHandle, #[allow(unused_variables)] title: String, ) -> Result<(), ApiError> { #[cfg(not(any(target_os = "ios", target_os = "android")))] { use tauri::Manager; if let Some(window) = app.get_webview_window("main") { window .set_title(&title) .map_api_err("Failed to set window title", ApiError::internal)?; } } Ok(()) } /// Open an external URL in the user's default browser, after validating it is an /// `http(s)` URL. /// /// This is the only sanctioned path from the frontend to the system opener. The /// JS `shell:allow-open` permission is intentionally NOT granted, so a frontend /// scripting bug (e.g. an XSS in rendered email content) cannot reach the opener /// with an attacker-chosen target — every call funnels through this scheme check /// (ultra-fuzz Run #28 Security MINOR-1). On mobile the caller falls back to a /// normal `window.open`. #[tauri::command] #[instrument(skip_all)] pub async fn open_external_url( #[allow(unused_variables)] app: tauri::AppHandle, url: String, ) -> Result<(), ApiError> { let trimmed = url.trim(); if !is_external_http_url(trimmed) { return Err(ApiError::bad_request("Only http(s) URLs can be opened externally")); } #[cfg(not(any(target_os = "ios", target_os = "android")))] { use tauri_plugin_shell::ShellExt; // TODO: migrate to tauri-plugin-opener; shell().open is deprecated. #[allow(deprecated)] app.shell() .open(trimmed.to_string(), None) .map_api_err("Failed to open URL", ApiError::internal)?; } Ok(()) } /// Whether `url` is an `http`/`https` URL safe to hand to the system opener. /// Rejects every other scheme (`file:`, `javascript:`, `data:`, custom-handler /// schemes) and scheme-relative or path inputs. pub(crate) fn is_external_http_url(url: &str) -> bool { let lower = url.to_ascii_lowercase(); lower.starts_with("https://") || lower.starts_with("http://") } #[cfg(test)] mod tests { use super::is_external_http_url; #[test] fn accepts_http_and_https() { assert!(is_external_http_url("https://accounts.google.com/o/oauth2/auth?x=1")); assert!(is_external_http_url("http://localhost:1421/callback")); assert!(is_external_http_url("HTTPS://EXAMPLE.COM")); } #[test] fn rejects_dangerous_and_non_http_schemes() { for bad in [ "file:///etc/passwd", "javascript:alert(1)", "data:text/html,", "mailto:a@b.com", "tel:+15551234", "smb://host/share", "/etc/passwd", "//evil.com", "ftp://host/x", "", ] { assert!(!is_external_http_url(bad), "should reject {bad:?}"); } } }