diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..5ed63389 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" diff --git a/.gitignore b/.gitignore index c71c710a..25c22726 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ target **/gen/schemas TODO_plan.md + +graphify-out/ diff --git a/Cargo.lock b/Cargo.lock index 122c2787..c4f97352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,6 +1262,46 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +[[package]] +name = "email_network" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "futures", + "hex", + "indexmap 2.13.0", + "kuchikiki", + "rand 0.9.3", + "reqwest 0.13.2", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "email_webview" +version = "0.1.0" +dependencies = [ + "email_network", + "gdk", + "gtk", + "nix 0.29.0", + "serde", + "serde_json", + "sysinfo", + "tauri", + "tokio", + "tracing", + "uuid", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "windows 0.62.2", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -3130,6 +3170,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3315,6 +3364,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -3961,6 +4020,8 @@ dependencies = [ "chrono", "dirs 6.0.0", "dunce", + "email_network", + "email_webview", "encoding_rs", "futures", "hex", @@ -4006,7 +4067,6 @@ dependencies = [ "url", "utf7-imap", "uuid", - "webkit2gtk", "windows 0.62.2", "zeroize", "zip", @@ -4940,9 +5000,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -5321,6 +5381,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5997,7 +6071,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.1.0", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -6024,9 +6098,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -6062,25 +6136,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.0", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" diff --git a/Cargo.toml b/Cargo.toml index 50c87d8c..c628acb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,16 @@ members = [ "src-tauri", "crates/html_transpiler", + "crates/email_network", + "crates/email_webview", ] resolver = "2" default-members = ["src-tauri"] + +[profile.release] +lto = "fat" +codegen-units = 1 +strip = true +opt-level = "s" +panic = "abort" diff --git a/TODO.md b/TODO.md index 8a1e0801..8c51071a 100644 --- a/TODO.md +++ b/TODO.md @@ -255,25 +255,25 @@ - [ ] 26.8 - UI: Detect dates in emails and suggest "Add to Calendar" - [ ] 27 - Isolated Webview with Watchdog - - [ ] 27.1 - Rust: Replace iframe with native Tauri Child Webview for email rendering - - [ ] 27.2 - UI: Create placeholder div in MessageViewBody for Child Webview positioning - - [ ] 27.3 - Rust: Position Child Webview as overlay aligned to the React placeholder bounds - - [ ] 27.4 - Rust: Serve email HTML to Child Webview via existing `postail://` protocol handler - - [ ] 27.5 - ACL: Create `email-webview` capability with zero permissions (deny all IPC) - - [ ] 27.6 - ACL: Whitelist only `email_heartbeat` command for the email webview window - - [ ] 27.7 - Rust: `email_heartbeat` command — accept token, rate-limit, update last-seen timestamp - - [ ] 27.8 - Rust: Inject heartbeat initialization script into Child Webview on creation - - [ ] 27.9 - JS: Heartbeat loop in injected script — invoke `email_heartbeat` every 100ms - - [ ] 27.10 - Rust: Watchdog task — spawn tokio task monitoring heartbeat timestamps per webview - - [ ] 27.11 - Rust: Extract Child Webview renderer PID after creation (platform-specific) - - [ ] 27.12 - Rust: Adaptive timeout — dynamically adjust watchdog threshold to avoid false positives - - [ ] 27.13 - Rust: Freeze process — SIGSTOP (Linux) / SuspendThread (Windows) on timeout - - [ ] 27.14 - Rust: Resume process — SIGCONT (Linux) / ResumeThread (Windows) on user request - - [ ] 27.15 - Rust: Optional per-process resource limits (memory/CPU) via /proc (Linux) / Job Objects (Windows) - - [ ] 27.16 - Rust: Emit `email_webview_frozen` event to frontend on freeze - - [ ] 27.17 - UI: `EmailFreezeNotice` component — overlay with freeze info, resume/keep-frozen buttons - - [ ] 27.18 - UI: Listen for `email_webview_frozen` in MessageView and show EmailFreezeNotice - - [ ] 27.19 - Rust: Emit `email_webview_resumed` event after successful resume - - [ ] 27.20 - UI: Handle resume event — hide notice, reset watchdog state display - - [ ] 27.21 - Rust: Cleanup — destroy Child Webview and kill process on message navigation + - [x] 27.1 - Rust: Replace iframe with native Tauri Child Webview for email rendering + - [x] 27.2 - UI: Create placeholder div in MessageViewBody for Child Webview positioning + - [x] 27.3 - Rust: Position Child Webview as overlay aligned to the React placeholder bounds + - [x] 27.4 - Rust: Serve email HTML to Child Webview via existing `postail://` protocol handler + - [x] 27.5 - ACL: Create `email-webview` capability with zero permissions (deny all IPC) + - [x] 27.6 - ACL: Whitelist only `email_heartbeat` command for the email webview window + - [x] 27.7 - Rust: `email_heartbeat` command — accept token, rate-limit, update last-seen timestamp + - [x] 27.8 - Rust: Inject heartbeat initialization script into Child Webview on creation + - [x] 27.9 - JS: Heartbeat loop in injected script — invoke `email_heartbeat` every 100ms + - [x] 27.10 - Rust: Watchdog task — spawn tokio task monitoring heartbeat timestamps per webview + - [x] 27.11 - Rust: Extract Child Webview renderer PID after creation (platform-specific) + - [x] 27.12 - Rust: Adaptive timeout — dynamically adjust watchdog threshold to avoid false positives + - [x] 27.13 - Rust: Freeze process — SIGSTOP (Linux) / SuspendThread (Windows) on timeout + - [x] 27.14 - Rust: Resume process — SIGCONT (Linux) / ResumeThread (Windows) on user request + - [x] 27.15 - Rust: Optional per-process resource limits (memory/CPU) via /proc (Linux) / Job Objects (Windows) + - [x] 27.16 - Rust: Emit `email_webview_frozen` event to frontend on freeze + - [x] 27.17 - UI: `EmailFreezeNotice` component — overlay with freeze info, resume/keep-frozen buttons + - [x] 27.18 - UI: Listen for `email_webview_frozen` in MessageView and show EmailFreezeNotice + - [x] 27.19 - Rust: Emit `email_webview_resumed` event after successful resume + - [x] 27.20 - UI: Handle resume event — hide notice, reset watchdog state display + - [x] 27.21 - Rust: Cleanup — destroy Child Webview and kill process on message navigation - [ ] 27.22 - Integration: End-to-end test with heavy HTML email triggering freeze/resume cycle diff --git a/crates/email_network/Cargo.toml b/crates/email_network/Cargo.toml new file mode 100644 index 00000000..40c77b0d --- /dev/null +++ b/crates/email_network/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "email_network" +version = "0.1.0" +edition = "2021" +description = "Network layer for Postail" +authors = ["FixeQD"] + +[dependencies] +base64 = "0.22" +futures = "0.3.31" +hex = "0.4" +indexmap = { version = "2", features = ["serde"] } +kuchikiki = "0.8.8-speedreader" +rand = { version = "0.9", features = ["small_rng"] } +reqwest = { version = "0.13", default-features = false, features = [ + "json", + "form", + "http2", + "native-tls", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" diff --git a/crates/email_network/src/error.rs b/crates/email_network/src/error.rs new file mode 100644 index 00000000..2ede0c5f --- /dev/null +++ b/crates/email_network/src/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum NetworkError { + #[error("request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("server returned {status} for {url}")] + BadStatus { status: u16, url: String }, + + #[error("resource at {url} exceeds size limit ({size} bytes)")] + TooLarge { url: String, size: usize }, +} + +impl From for String { + fn from(err: NetworkError) -> Self { + err.to_string() + } +} + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("encryption failed: {0}")] + Encryption(String), + + #[error("decryption failed: {0}")] + Decryption(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("metadata serialization failed: {0}")] + Metadata(String), +} + +impl From for String { + fn from(err: CacheError) -> Self { + err.to_string() + } +} diff --git a/crates/email_network/src/lib.rs b/crates/email_network/src/lib.rs new file mode 100644 index 00000000..ed24cea4 --- /dev/null +++ b/crates/email_network/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod network; diff --git a/src-tauri/src/network/cache.rs b/crates/email_network/src/network/cache.rs similarity index 91% rename from src-tauri/src/network/cache.rs rename to crates/email_network/src/network/cache.rs index 23242fab..944ec8be 100644 --- a/src-tauri/src/network/cache.rs +++ b/crates/email_network/src/network/cache.rs @@ -1,5 +1,4 @@ use crate::error::{CacheError, NetworkError}; -use crate::globals::SECURITY; use crate::network::fetcher::{ResourceFetcher, ResourceResponse}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use indexmap::IndexMap; @@ -13,9 +12,9 @@ use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime}; use tracing::{info, warn}; -const RAM_LIMIT: usize = 32 * 1024 * 1024; // 32 MB -const DISK_LIMIT: u64 = 512 * 1024 * 1024; // 512 MB -const RAM_TIER_MAX: usize = 512 * 1024; // files > 512 KB go disk-only +const RAM_LIMIT: usize = 32 * 1024 * 1024; +const DISK_LIMIT: u64 = 512 * 1024 * 1024; +const RAM_TIER_MAX: usize = 512 * 1024; const METADATA_FILE: &str = "resource_cache_meta.json"; pub static RESOURCE_CACHE: OnceLock = OnceLock::new(); @@ -49,10 +48,16 @@ pub struct ResourceCache { disk_total: AtomicU64, cache_dir: PathBuf, fetcher: ResourceFetcher, + encrypt: Arc Result, String> + Send + Sync>, + decrypt: Arc Result, String> + Send + Sync>, } impl ResourceCache { - pub fn new(cache_dir: PathBuf) -> Self { + pub fn new( + cache_dir: PathBuf, + encrypt: Arc Result, String> + Send + Sync>, + decrypt: Arc Result, String> + Send + Sync>, + ) -> Self { std::fs::create_dir_all(&cache_dir).ok(); let (disk_entries, disk_total) = Self::load_metadata(&cache_dir); @@ -64,6 +69,8 @@ impl ResourceCache { disk_total: AtomicU64::new(disk_total), cache_dir, fetcher: ResourceFetcher::new(), + encrypt, + decrypt, } } @@ -191,13 +198,7 @@ impl ResourceCache { self.evict_disk_if_needed(size); - let security = SECURITY - .try_lock() - .map_err(|e| CacheError::SecurityUnavailable(e.to_string()))?; - let encrypted = security - .encrypt(data) - .map_err(|e| CacheError::Encryption(e.to_string()))?; - drop(security); + let encrypted = (self.encrypt)(data).map_err(|e| CacheError::Encryption(e))?; let file_path = self.cache_dir.join(&hash); std::fs::write(&file_path, &encrypted)?; @@ -241,20 +242,13 @@ impl ResourceCache { let file_path = self.cache_dir.join(&hash); let encrypted = std::fs::read(&file_path)?; - let security = SECURITY - .try_lock() - .map_err(|e| CacheError::SecurityUnavailable(e.to_string()))?; - let decrypted = security - .decrypt(&encrypted) - .map_err(|e| CacheError::Decryption(e.to_string()))?; - drop(security); + let decrypted = (self.decrypt)(&encrypted).map_err(|e| CacheError::Decryption(e))?; self.save_metadata(); Ok(Some((decrypted, mime))) } pub async fn get_or_fetch(&self, url: &str) -> Result<(Arc>, String), NetworkError> { - // 1. RAM hit { let mut ram = self.ram.lock().unwrap(); if let Some(entry) = ram.get_mut(url) { @@ -264,7 +258,6 @@ impl ResourceCache { } } - // 2. Disk hit if let Some((bytes, mime)) = self.read_disk(url) { info!("resource cache: disk hit url={url}"); let data = Arc::new(bytes); @@ -272,7 +265,6 @@ impl ResourceCache { return Ok((data, mime)); } - // 3. Fetch info!("resource cache: cache miss, fetching url={url}"); let ResourceResponse { bytes, mime_type } = self.fetcher.fetch(url).await?; diff --git a/src-tauri/src/network/fetcher.rs b/crates/email_network/src/network/fetcher.rs similarity index 94% rename from src-tauri/src/network/fetcher.rs rename to crates/email_network/src/network/fetcher.rs index 440a11a1..0cb9e8f0 100644 --- a/src-tauri/src/network/fetcher.rs +++ b/crates/email_network/src/network/fetcher.rs @@ -6,7 +6,7 @@ use tracing::{info, warn}; const FETCH_TIMEOUT_SECS: u64 = 30; const MAX_REDIRECTS: usize = 5; -const MAX_RESPONSE_BYTES: usize = 50 * 1024 * 1024; // 50 MB hard cap per resource +const MAX_RESPONSE_BYTES: usize = 50 * 1024 * 1024; pub struct ResourceResponse { pub bytes: Vec, @@ -19,7 +19,6 @@ pub struct ResourceFetcher { impl ResourceFetcher { pub fn new() -> Self { - // Damn, tracking systems that use suffixes are cooked 💀 let suffix: String = rand::rng() .sample_iter(Alphanumeric) .take(8) diff --git a/src-tauri/src/network/mod.rs b/crates/email_network/src/network/mod.rs similarity index 100% rename from src-tauri/src/network/mod.rs rename to crates/email_network/src/network/mod.rs diff --git a/src-tauri/src/network/null_proxy.rs b/crates/email_network/src/network/null_proxy.rs similarity index 93% rename from src-tauri/src/network/null_proxy.rs rename to crates/email_network/src/network/null_proxy.rs index 37696c8a..a614e4e4 100644 --- a/src-tauri/src/network/null_proxy.rs +++ b/crates/email_network/src/network/null_proxy.rs @@ -1,4 +1,3 @@ -/// Null proxy server — accepts connections and returns 502 for every request. use std::net::SocketAddr; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; diff --git a/src-tauri/src/network/rewriter.rs b/crates/email_network/src/network/rewriter.rs similarity index 92% rename from src-tauri/src/network/rewriter.rs rename to crates/email_network/src/network/rewriter.rs index 6c4cc562..f1bd6483 100644 --- a/src-tauri/src/network/rewriter.rs +++ b/crates/email_network/src/network/rewriter.rs @@ -31,7 +31,6 @@ struct AnalysisResult { pub urls: Vec, } -/// CPU-bound: parse HTML once, both detect external resources and collect URLs. fn analyze_html(html: &str) -> AnalysisResult { let mut urls: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); @@ -89,7 +88,6 @@ fn collect_css_urls( } } -/// CPU-bound: string replacement pass. Runs inside spawn_blocking. fn apply_replacements(html: String, replacements: HashMap) -> String { if replacements.is_empty() { return html; @@ -107,7 +105,6 @@ fn apply_replacements(html: String, replacements: HashMap) -> St } pub async fn rewrite_external_resources(html: &str, allow_external: bool) -> RewriteResult { - // Single analysis pass - parse HTML once to both detect and collect external URLs let html_owned = html.to_string(); let analysis = tokio::task::spawn_blocking(move || analyze_html(&html_owned)) .await @@ -138,7 +135,6 @@ pub async fn rewrite_external_resources(html: &str, allow_external: bool) -> Rew let urls = analysis.urls; - // Fetch all URLs in parallel — no more sequential waterfall let fetch_futures = urls.iter().map(|url| { let url = url.clone(); async move { @@ -165,7 +161,6 @@ pub async fn rewrite_external_resources(html: &str, allow_external: bool) -> Rew } } - // Offload string replacement (potentially large HTML) to blocking thread pool let html_owned = html.to_string(); let rewritten = tokio::task::spawn_blocking(move || apply_replacements(html_owned, replacements)) diff --git a/crates/email_webview/Cargo.toml b/crates/email_webview/Cargo.toml new file mode 100644 index 00000000..5504cbfc --- /dev/null +++ b/crates/email_webview/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "email_webview" +version = "0.1.0" +edition = "2021" +description = "Embedded email webview management for Postail" +authors = ["FixeQD"] + +[dependencies] +email_network = { path = "../email_network" } +tauri = { version = "2", features = [ + "protocol-asset", + "tray-icon", + "unstable", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" + +[target.'cfg(target_os = "linux")'.dependencies] +nix = { version = "0.29", features = [ + "process", + "signal", + "user", + "socket", + "fs", + "inotify", +] } +webkit2gtk = { version = "2.0", features = ["v2_32"] } +gtk = { version = "0.18", features = ["v3_24"] } +gdk = "0.18" +sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.62.2", features = [ + "Foundation", + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ProcessStatus", +] } +windows_wv = { package = "windows", version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_DirectComposition", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", +] } +webview2-com = "0.38" diff --git a/crates/email_webview/src/commands.rs b/crates/email_webview/src/commands.rs new file mode 100644 index 00000000..c9c4fcca --- /dev/null +++ b/crates/email_webview/src/commands.rs @@ -0,0 +1,136 @@ +use crate::state::EmbeddedEmailState; +use crate::watchdog::WatchdogState; +#[cfg(target_os = "linux")] +use crate::webview::linux; +#[cfg(target_os = "windows")] +use crate::webview::windows; +use tauri::{command, AppHandle, Emitter, State}; + +#[command] +pub fn create_email_webview( + app: AppHandle, + state: State<'_, EmbeddedEmailState>, + watchdog: State<'_, WatchdogState>, +) -> Result<(), String> { + #[cfg(target_os = "linux")] + return linux::create(app, state.inner().clone(), watchdog.inner().clone()); + + #[cfg(target_os = "windows")] + { + let _ = state; + return windows::create(app, watchdog.inner().clone()); + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + Err("platform not supported".into()) +} + +#[command] +pub fn update_email_webview_bounds( + app: AppHandle, + state: State<'_, EmbeddedEmailState>, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let _ = app; + return linux::update_bounds(state.inner().clone(), x, y, width, height); + } + + #[cfg(target_os = "windows")] + { + let _ = state; + return windows::update_bounds(app, x, y, width, height); + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + Ok(()) +} + +#[command] +pub fn destroy_email_webview(app: AppHandle, state: State<'_, EmbeddedEmailState>) { + #[cfg(target_os = "linux")] + { + let _ = app; + linux::destroy(state.inner().clone()); + } + + #[cfg(target_os = "windows")] + { + let _ = state; + windows::destroy(app); + } +} + +#[command] +pub fn reload_email_webview( + app: AppHandle, + state: State<'_, EmbeddedEmailState>, +) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let _ = app; + return linux::reload(state.inner().clone()); + } + + #[cfg(target_os = "windows")] + { + let _ = state; + return windows::reload(app); + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = app; + let _ = state; + Ok(()) + } +} + +#[command] +pub async fn resume_email_webview( + app: AppHandle, + state: State<'_, WatchdogState>, +) -> Result<(), String> { + let mut data = state.0.lock().unwrap(); + + if !data.is_frozen { + return Ok(()); + } + + if let Some(pid) = data.pid { + #[cfg(target_os = "linux")] + { + let nix_pid = nix::unistd::Pid::from_raw(pid as i32); + if let Err(e) = nix::sys::signal::kill(nix_pid, nix::sys::signal::Signal::SIGCONT) { + tracing::error!(pid, err = %e, "SIGCONT failed"); + return Err(format!("SIGCONT failed: {}", e)); + } else { + tracing::info!(pid, "email webview unfrozen"); + } + } + #[cfg(target_os = "windows")] + { + if let Err(e) = crate::watchdog::resume_process_windows(pid) { + tracing::error!(pid, err = %e, "ResumeThread failed"); + return Err(e); + } else { + tracing::info!(pid, "email webview unfrozen"); + } + } + } + + data.is_frozen = false; + data.last_heartbeat = std::time::Instant::now(); + data.resumed_at = Some(std::time::Instant::now()); + drop(data); + + if let Err(e) = app.emit("email_webview_resumed", ()) { + tracing::error!("failed to emit email_webview_resumed: {e}"); + } + + Ok(()) +} diff --git a/crates/email_webview/src/lib.rs b/crates/email_webview/src/lib.rs new file mode 100644 index 00000000..9e3a0cbe --- /dev/null +++ b/crates/email_webview/src/lib.rs @@ -0,0 +1,5 @@ +pub mod commands; +pub mod policy; +pub mod state; +pub mod watchdog; +pub mod webview; diff --git a/crates/email_webview/src/policy.rs b/crates/email_webview/src/policy.rs new file mode 100644 index 00000000..15014dc6 --- /dev/null +++ b/crates/email_webview/src/policy.rs @@ -0,0 +1,206 @@ +use tauri::{Runtime, WebviewWindow}; + +#[cfg(target_os = "linux")] +use tracing::{info, warn}; + +#[cfg(target_os = "windows")] +use std::borrow::Cow; +#[cfg(target_os = "windows")] +use std::sync::{Arc, OnceLock}; +#[cfg(target_os = "windows")] +use tauri::http::Response; + +#[cfg(target_os = "windows")] +type RouteHandler = Arc Option>> + Send + Sync>; + +#[cfg(target_os = "windows")] +static ROUTE_HANDLER: OnceLock = OnceLock::new(); + +#[cfg(target_os = "windows")] +pub fn set_route_handler(handler: RouteHandler) { + let _ = ROUTE_HANDLER.set(handler); +} + +pub fn install_network_block(window: &WebviewWindow, proxy_port: u16) { + #[cfg(target_os = "linux")] + install_linux(window, proxy_port); + + #[cfg(not(target_os = "linux"))] + { + let _ = window; + let _ = proxy_port; + } +} + +#[cfg(target_os = "linux")] +fn install_linux(window: &WebviewWindow, proxy_port: u16) { + use webkit2gtk::{NetworkProxyMode, NetworkProxySettings, WebViewExt, WebsiteDataManagerExt}; + + let result = window.with_webview(move |webview| { + let wk = webview.inner(); + + let proxy_uri = format!("http://127.0.0.1:{proxy_port}"); + let mut proxy = NetworkProxySettings::new( + Some(proxy_uri.as_str()), + &["localhost", "127.0.0.1", "::1", "*.localhost"], + ); + + if let Some(manager) = wk.website_data_manager() { + manager.set_network_proxy_settings(NetworkProxyMode::Custom, Some(&mut proxy)); + info!("webview network block: null proxy installed on port {proxy_port}"); + } else { + warn!("webview network block: could not get WebsiteDataManager"); + } + }); + + if let Err(e) = result { + warn!("webview network block: failed to install: {e}"); + } +} + +#[cfg(target_os = "windows")] +pub fn register_webview2_resource_handler( + wv: &webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2, + env: &webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Environment, + _app: tauri::AppHandle, +) { + use crate::state::SendWidget; + use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2WebResourceRequestedEventArgs, COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL, + }; + use webview2_com::WebResourceRequestedEventHandler; + use windows_wv::core::PWSTR; + use windows_wv::Win32::Foundation::HGLOBAL; + use windows_wv::Win32::System::Com::StructuredStorage::CreateStreamOnHGlobal; + use windows_wv::Win32::System::Com::{CoTaskMemFree, IStream, STREAM_SEEK_SET}; + + let Some(route_handler) = ROUTE_HANDLER.get() else { + tracing::error!("[webview/win] route handler not set"); + return; + }; + let route_handler = route_handler.clone(); + + let filter: Vec = "http://postail.localhost/*\0".encode_utf16().collect(); + if let Err(e) = unsafe { + wv.AddWebResourceRequestedFilter( + windows_wv::core::PCWSTR(filter.as_ptr()), + COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL, + ) + } { + tracing::error!("[webview/win] AddWebResourceRequestedFilter: {e:?}"); + return; + } + + fn stream_from_bytes(body: &[u8]) -> Option { + unsafe { + let stream = CreateStreamOnHGlobal(HGLOBAL::default(), true).ok()?; + if !body.is_empty() { + let mut written = 0u32; + if stream + .Write(body.as_ptr() as _, body.len() as u32, Some(&mut written)) + .is_err() + { + return None; + } + let _ = stream.Seek(0, STREAM_SEEK_SET, None); + } + Some(stream) + } + } + + let env = SendWidget(env.clone()); + let handler = WebResourceRequestedEventHandler::create(Box::new( + move |_wv, args: Option| { + let args = match args { + Some(a) => a, + None => return Ok(()), + }; + let request = unsafe { args.Request()? }; + let mut uri = PWSTR::null(); + unsafe { + request.Uri(&mut uri)?; + } + if uri.is_null() { + return Ok(()); + } + let uri_str = unsafe { uri.to_string() }; + unsafe { + CoTaskMemFree(Some(uri.0 as _)); + } + let uri_str = uri_str?; + + let path_and_query = uri_str + .strip_prefix("http://postail.localhost") + .unwrap_or("/"); + let (path, query) = path_and_query + .split_once('?') + .unwrap_or((path_and_query, "")); + + let response = route_handler(path, query); + + let response = match response { + Some(resp) => resp, + None => { + let body = b"Not Found".to_vec(); + if let Some(stream) = stream_from_bytes(&body) { + let headers_w: Vec = + "Content-Type: text/plain\r\n\0".encode_utf16().collect(); + let reason_w: Vec = "Not Found\0".encode_utf16().collect(); + if let Ok(resp) = unsafe { + env.0.CreateWebResourceResponse( + Some(&stream), + 404, + windows_wv::core::PCWSTR(reason_w.as_ptr()), + windows_wv::core::PCWSTR(headers_w.as_ptr()), + ) + } { + let _ = unsafe { args.SetResponse(&resp) }; + } + } + return Ok(()); + } + }; + + let status = response.status().as_u16() as i32; + let body = response.body().as_ref().to_vec(); + + let mut headers = String::new(); + for (name, value) in response.headers().iter() { + headers.push_str(name.as_str()); + headers.push_str(": "); + headers.push_str(value.to_str().unwrap_or_default()); + headers.push_str("\r\n"); + } + headers.push('\0'); + let headers_w: Vec = headers.encode_utf16().collect(); + + let reason = response.status().canonical_reason().unwrap_or("OK"); + let reason_w: Vec = format!("{reason}\0").encode_utf16().collect(); + + let stream: Option = stream_from_bytes(&body); + + match unsafe { + env.0.CreateWebResourceResponse( + stream.as_ref(), + status, + windows_wv::core::PCWSTR(reason_w.as_ptr()), + windows_wv::core::PCWSTR(headers_w.as_ptr()), + ) + } { + Ok(response) => { + let _ = unsafe { args.SetResponse(&response) }; + } + Err(e) => { + tracing::error!("[webview/win] CreateWebResourceResponse: {e:?}"); + } + } + + Ok(()) + }, + )); + + let mut token = Default::default(); + if let Err(e) = unsafe { wv.add_WebResourceRequested(&handler, &mut token) } { + tracing::error!("[webview/win] add_WebResourceRequested: {e:?}"); + } +} diff --git a/crates/email_webview/src/state.rs b/crates/email_webview/src/state.rs new file mode 100644 index 00000000..050712d1 --- /dev/null +++ b/crates/email_webview/src/state.rs @@ -0,0 +1,58 @@ +use std::sync::{Arc, Mutex}; + +#[cfg(target_os = "windows")] +use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2CompositionController, ICoreWebView2Controller, +}; +#[cfg(target_os = "windows")] +use windows_wv::Win32::Graphics::DirectComposition::{ + IDCompositionDevice, IDCompositionTarget, IDCompositionVisual, +}; + +pub struct SendWidget(pub T); +unsafe impl Send for SendWidget {} +unsafe impl Sync for SendWidget {} + +#[cfg(target_os = "windows")] +pub struct WinViewInner { + pub comp_ctrl: Option, + pub controller: Option, + pub dcomp_device: Option, + pub dcomp_target: Option, + pub dcomp_visual: Option, + pub main_hwnd: isize, + pub pending_bounds: Option<(f64, f64, f64, f64)>, +} + +#[cfg(target_os = "windows")] +unsafe impl Send for WinViewInner {} +#[cfg(target_os = "windows")] +unsafe impl Sync for WinViewInner {} + +#[cfg(target_os = "windows")] +impl Default for WinViewInner { + fn default() -> Self { + Self { + comp_ctrl: None, + controller: None, + dcomp_device: None, + dcomp_target: None, + dcomp_visual: None, + main_hwnd: 0, + pending_bounds: None, + } + } +} + +#[derive(Clone, Default)] +pub struct EmbeddedEmailState { + #[cfg(target_os = "linux")] + pub bounds: Arc>, + #[cfg(target_os = "linux")] + pub overlay: Arc>>>, + #[cfg(target_os = "linux")] + pub email_wv: Arc>>>, + + #[cfg(target_os = "windows")] + pub win: Arc>, +} diff --git a/crates/email_webview/src/watchdog.rs b/crates/email_webview/src/watchdog.rs new file mode 100644 index 00000000..aa13dabe --- /dev/null +++ b/crates/email_webview/src/watchdog.rs @@ -0,0 +1,254 @@ +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::Emitter; + +pub struct WatchdogData { + pub last_heartbeat: Instant, + pub pid: Option, + pub token: String, + pub is_frozen: bool, + pub created_at: Instant, + pub resumed_at: Option, +} + +impl Default for WatchdogData { + fn default() -> Self { + Self { + last_heartbeat: Instant::now(), + pid: None, + token: String::new(), + is_frozen: false, + created_at: Instant::now(), + resumed_at: None, + } + } +} + +#[derive(Clone, Default)] +pub struct WatchdogState(pub Arc>); + +const RATE_LIMIT: Duration = Duration::from_millis(50); + +pub fn email_heartbeat(state: &WatchdogState, token: &str) -> Result { + let mut data = state.0.lock().unwrap(); + + if data.token.is_empty() { + return Err(()); + } + + if data.token != token { + return Err(()); + } + + let now = Instant::now(); + if now.duration_since(data.last_heartbeat) < RATE_LIMIT { + return Err(()); + } + + data.last_heartbeat = now; + + let next = uuid::Uuid::new_v4().to_string(); + data.token = next.clone(); + + Ok(next) +} + +#[derive(Clone, serde::Serialize)] +pub struct FreezeStats { + pub silent_for_ms: u64, + pub pid: Option, + pub memory_bytes: u64, +} + +pub async fn run_watchdog_loop(app: tauri::AppHandle, state: WatchdogState) { + loop { + tokio::time::sleep(Duration::from_millis(200)).await; + + let mut data = state.0.lock().unwrap(); + + if data.token.is_empty() || data.is_frozen { + continue; + } + + let now = Instant::now(); + + let threshold = if let Some(resumed_at) = data.resumed_at { + let since_resume = now.duration_since(resumed_at); + if since_resume < Duration::from_secs(10) { + Duration::from_secs(10) + } else if since_resume < Duration::from_secs(15) { + Duration::from_millis(3000) + } else { + Duration::from_millis(500) + } + } else { + if now.duration_since(data.created_at) < Duration::from_secs(5) { + Duration::from_millis(3000) + } else { + Duration::from_millis(500) + } + }; + + let silent_for = now.duration_since(data.last_heartbeat); + if silent_for < threshold { + continue; + } + + data.is_frozen = true; + let stats = FreezeStats { + silent_for_ms: silent_for.as_millis() as u64, + pid: data.pid, + memory_bytes: data.pid.map(read_renderer_memory).unwrap_or(0), + }; + + drop(data); + + if let Some(pid) = stats.pid { + #[cfg(target_os = "linux")] + { + let nix_pid = nix::unistd::Pid::from_raw(pid as i32); + if let Err(e) = nix::sys::signal::kill(nix_pid, nix::sys::signal::Signal::SIGSTOP) { + tracing::error!(pid, err = %e, "SIGSTOP failed"); + } else { + tracing::warn!(pid, silent_ms = stats.silent_for_ms, "email webview frozen"); + } + } + #[cfg(target_os = "windows")] + { + match suspend_process_windows(pid) { + Ok(()) => { + tracing::warn!(pid, silent_ms = stats.silent_for_ms, "email webview frozen") + } + Err(e) => tracing::error!(pid, err = %e, "SuspendThread failed"), + } + } + } + + if let Err(e) = app.emit("email_webview_frozen", &stats) { + tracing::error!("failed to emit email_webview_frozen: {e}"); + } + } +} + +#[cfg(target_os = "windows")] +fn suspend_process_windows(pid: u32) -> Result<(), String> { + use windows::Win32::Foundation::{CloseHandle, FALSE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows::Win32::System::Threading::{OpenThread, SuspendThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(|e| e.to_string())?; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if Thread32First(snapshot, &mut entry).is_ok() { + loop { + if entry.th32OwnerProcessID == pid { + if let Ok(thread) = + OpenThread(THREAD_SUSPEND_RESUME, FALSE.into(), entry.th32ThreadID) + { + SuspendThread(thread); + let _ = CloseHandle(thread); + } + } + if Thread32Next(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +pub(crate) fn resume_process_windows(pid: u32) -> Result<(), String> { + use windows::Win32::Foundation::{CloseHandle, FALSE}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(|e| e.to_string())?; + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if Thread32First(snapshot, &mut entry).is_ok() { + loop { + if entry.th32OwnerProcessID == pid { + if let Ok(thread) = + OpenThread(THREAD_SUSPEND_RESUME, FALSE.into(), entry.th32ThreadID) + { + ResumeThread(thread); + let _ = CloseHandle(thread); + } + } + if Thread32Next(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + Ok(()) +} + +fn read_renderer_memory(pid: u32) -> u64 { + #[cfg(target_os = "linux")] + { + let path = format!("/proc/{pid}/statm"); + let Ok(content) = std::fs::read_to_string(&path) else { + return 0; + }; + let rss_pages: u64 = content + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + rss_pages * 4096 + } + + #[cfg(target_os = "windows")] + { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::ProcessStatus::{ + GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, + }; + use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; + + unsafe { + let Ok(handle) = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid) + else { + return 0; + }; + + let mut pmc = PROCESS_MEMORY_COUNTERS::default(); + let ok = GetProcessMemoryInfo( + handle, + &mut pmc, + std::mem::size_of::() as u32, + ); + let _ = CloseHandle(handle); + + if ok.is_ok() { + pmc.WorkingSetSize as u64 + } else { + 0 + } + } + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + 0 + } +} diff --git a/crates/email_webview/src/webview/linux.rs b/crates/email_webview/src/webview/linux.rs new file mode 100644 index 00000000..a3aaf244 --- /dev/null +++ b/crates/email_webview/src/webview/linux.rs @@ -0,0 +1,178 @@ +use crate::state::{EmbeddedEmailState, SendWidget}; +use crate::watchdog::WatchdogState; +use gtk::prelude::*; +use tauri::{AppHandle, Manager}; +use webkit2gtk::WebViewExt; + +fn find_new_renderer_pid(pids_before: &std::collections::HashSet) -> Option { + tracing::info!("LoadEvent::Started triggered, diffing processes..."); + let mut sys = sysinfo::System::new_all(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + let my_pid = sysinfo::Pid::from_u32(std::process::id()); + + for (pid, process) in sys.processes() { + if !pids_before.contains(pid) { + let mut current = *pid; + let mut is_child = false; + while let Some(proc) = sys.process(current) { + if let Some(parent) = proc.parent() { + if parent == my_pid { + is_child = true; + break; + } + current = parent; + } else { + break; + } + } + let name = format!("{:?}", process.name()).to_lowercase(); + let pid_u32 = pid.as_u32(); + tracing::info!(pid = pid_u32, name = ?name, is_child, "new process detected in diff"); + if is_child + && (name.contains("webkit") + || name.contains("webprocess") + || name.contains("bwrap")) + { + tracing::info!(pid = pid_u32, name = ?name, "matched target process!"); + return Some(pid_u32); + } + } + } + tracing::info!("failed to find matching process in diff"); + None +} + +pub fn create( + app: AppHandle, + state: EmbeddedEmailState, + watchdog: WatchdogState, +) -> Result<(), String> { + if state.email_wv.lock().unwrap().is_some() { + let s = state.clone(); + gtk::glib::MainContext::default().invoke(move || { + let wv = s.email_wv.lock().unwrap(); + let ov = s.overlay.lock().unwrap(); + if let (Some(wv), Some(ov)) = (wv.as_ref(), ov.as_ref()) { + wv.0.load_uri("postail://localhost/message/current"); + wv.0.show(); + ov.0.queue_resize(); + } + }); + return Ok(()); + } + + let bounds_arc = state.bounds.clone(); + let overlay_arc = state.overlay.clone(); + let wv_arc = state.email_wv.clone(); + let watchdog_arc = watchdog.clone(); + + let main_window = app + .get_webview_window("main") + .ok_or("main window not found")?; + + main_window + .with_webview(move |main_wv| { + use webkit2gtk::glib::Cast; + + let main_gtk_wv: &webkit2gtk::WebView = &main_wv.inner(); + let widget = main_gtk_wv.upcast_ref::(); + let toplevel = widget + .toplevel() + .and_then(|t| t.downcast::().ok()) + .expect("GtkWindow toplevel not found"); + + let original_child = toplevel.child().expect("window has no child widget"); + toplevel.remove(&original_child); + + let overlay = gtk::Overlay::new(); + overlay.add(&original_child); + toplevel.add(&overlay); + + let ctx = main_gtk_wv + .web_context() + .expect("no WebContext on main webview"); + + let mut sys = sysinfo::System::new_all(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); + let pids_before: std::collections::HashSet<_> = + sys.processes().keys().copied().collect(); + + let email_wv = webkit2gtk::WebView::with_context(&ctx); + + if let Some(settings) = WebViewExt::settings(&email_wv) { + use webkit2gtk::SettingsExt; + settings.set_javascript_can_open_windows_automatically(false); + settings.set_allow_modal_dialogs(false); + } + + overlay.add_overlay(&email_wv); + overlay.set_overlay_pass_through(&email_wv, false); + + let bounds_for_signal = bounds_arc.clone(); + overlay.connect_get_child_position(move |_ov, _widget| { + let (x, y, w, h) = *bounds_for_signal.lock().unwrap(); + Some(gdk::Rectangle::new(x, y, w.max(1), h.max(1))) + }); + + let watchdog_for_signal = watchdog_arc.clone(); + email_wv.connect_load_changed(move |_wv, event| { + if event == webkit2gtk::LoadEvent::Started { + if let Some(pid) = find_new_renderer_pid(&pids_before) { + watchdog_for_signal.0.lock().unwrap().pid = Some(pid); + tracing::info!(pid, "email renderer PID captured via sysinfo diff"); + } + } + }); + + overlay.show_all(); + email_wv.hide(); + email_wv.load_uri("postail://localhost/message/current"); + + *overlay_arc.lock().unwrap() = Some(SendWidget(overlay)); + *wv_arc.lock().unwrap() = Some(SendWidget(email_wv)); + + tracing::info!("embedded email WebView created inside GtkOverlay"); + }) + .map_err(|e| format!("with_webview failed: {e}"))?; + + Ok(()) +} + +pub fn update_bounds( + state: EmbeddedEmailState, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + *state.bounds.lock().unwrap() = (x as i32, y as i32, width as i32, height as i32); + gtk::glib::MainContext::default().invoke(move || { + let wv = state.email_wv.lock().unwrap(); + let ov = state.overlay.lock().unwrap(); + if let (Some(wv), Some(ov)) = (wv.as_ref(), ov.as_ref()) { + wv.0.show(); + ov.0.queue_resize(); + } + }); + Ok(()) +} + +pub fn destroy(state: EmbeddedEmailState) { + gtk::glib::MainContext::default().invoke(move || { + if let Some(wv) = state.email_wv.lock().unwrap().as_ref() { + wv.0.hide(); + wv.0.terminate_web_process(); + tracing::info!("embedded email WebView hidden and renderer process terminated"); + } + }); +} + +pub fn reload(state: EmbeddedEmailState) -> Result<(), String> { + gtk::glib::MainContext::default().invoke(move || { + if let Some(wv) = state.email_wv.lock().unwrap().as_ref() { + wv.0.load_uri("postail://localhost/message/current"); + tracing::info!("email WebView reloaded"); + } + }); + Ok(()) +} diff --git a/crates/email_webview/src/webview/mod.rs b/crates/email_webview/src/webview/mod.rs new file mode 100644 index 00000000..c5b13d0f --- /dev/null +++ b/crates/email_webview/src/webview/mod.rs @@ -0,0 +1,4 @@ +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "windows")] +pub mod windows; diff --git a/crates/email_webview/src/webview/windows.rs b/crates/email_webview/src/webview/windows.rs new file mode 100644 index 00000000..78dbc485 --- /dev/null +++ b/crates/email_webview/src/webview/windows.rs @@ -0,0 +1,409 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::state::{EmbeddedEmailState, WinViewInner}; +use crate::watchdog::WatchdogState; +use tauri::{AppHandle, Manager}; + +use windows_wv::core::Interface; +use windows_wv::Win32::Foundation::{HMODULE, HWND, RECT}; +use windows_wv::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE; +use windows_wv::Win32::Graphics::Direct3D11::{ + D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, +}; +use windows_wv::Win32::Graphics::DirectComposition::{ + DCompositionCreateDevice, IDCompositionDevice, IDCompositionTarget, IDCompositionVisual, +}; +use windows_wv::Win32::Graphics::Dxgi::IDXGIDevice; + +use webview2_com::Microsoft::Web::WebView2::Win32::{ + CreateCoreWebView2EnvironmentWithOptions, ICoreWebView2, ICoreWebView2CompositionController, + ICoreWebView2Controller, ICoreWebView2Environment, ICoreWebView2Environment3, +}; +use webview2_com::{ + CreateCoreWebView2CompositionControllerCompletedHandler, + CreateCoreWebView2EnvironmentCompletedHandler, +}; + +pub fn create(app: AppHandle, watchdog: WatchdogState) -> Result<(), String> { + let app2 = app.clone(); + app.run_on_main_thread(move || create_on_main(app2, watchdog)) + .map_err(|e| format!("run_on_main_thread: {e}")) +} + +pub fn update_bounds(app: AppHandle, x: f64, y: f64, w: f64, h: f64) -> Result<(), String> { + let app2 = app.clone(); + app.run_on_main_thread(move || update_bounds_on_main(&app2, x, y, w, h)) + .map_err(|e| format!("run_on_main_thread: {e}")) +} + +pub fn destroy(app: AppHandle) { + let app2 = app.clone(); + let _ = app.run_on_main_thread(move || destroy_on_main(&app2)); +} + +pub fn reload(app: AppHandle) -> Result<(), String> { + let app2 = app.clone(); + app.run_on_main_thread(move || reload_on_main(&app2)) + .map_err(|e| format!("run_on_main_thread: {e}")) +} + +fn create_on_main(app: AppHandle, watchdog: WatchdogState) { + let state = app.state::(); + + if state.win.lock().unwrap().comp_ctrl.is_some() { + reload_on_main(&app); + return; + } + + let window = match app.get_webview_window("main") { + Some(w) => w, + None => { + tracing::error!("[webview/win] main window not found"); + return; + } + }; + + let hwnd_isize: isize = match window.hwnd() { + Ok(h) => h.0 as isize, + Err(e) => { + tracing::error!("[webview/win] hwnd() failed: {e}"); + return; + } + }; + state.win.lock().unwrap().main_hwnd = hwnd_isize; + + let win_arc = state.win.clone(); + let wd = watchdog.clone(); + let app_for_env = app.clone(); + + let env_handler = + CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new(move |hr, env| { + if hr.is_err() { + tracing::error!("[webview/win] env creation failed: {hr:?}"); + return Ok(()); + } + let env = match env { + Some(e) => e, + None => { + tracing::error!("[webview/win] env is None"); + return Ok(()); + } + }; + + let hwnd = HWND(win_arc.lock().unwrap().main_hwnd as *mut _); + + let (dev, tgt, vis) = match setup_dcomp(hwnd) { + Ok(v) => v, + Err(e) => { + tracing::error!("[webview/win] DComp setup: {e}"); + return Ok(()); + } + }; + { + let mut g = win_arc.lock().unwrap(); + g.dcomp_device = Some(dev); + g.dcomp_target = Some(tgt); + g.dcomp_visual = Some(vis); + } + + let env3: ICoreWebView2Environment3 = match env.cast() { + Ok(e) => e, + Err(e) => { + tracing::error!("[webview/win] env→env3: {e:?}"); + return Ok(()); + } + }; + + let win_for_ctrl = win_arc.clone(); + let wd_for_ctrl = wd.clone(); + let env_for_ctrl = crate::state::SendWidget(env.clone()); + let app_for_ctrl = app_for_env.clone(); + + let ctrl_handler = CreateCoreWebView2CompositionControllerCompletedHandler::create( + Box::new(move |hr, comp_ctrl| { + on_ctrl_created( + hr, + comp_ctrl, + &win_for_ctrl, + &wd_for_ctrl, + &env_for_ctrl.0, + &app_for_ctrl, + ); + Ok(()) + }), + ); + + unsafe { + if let Err(e) = env3.CreateCoreWebView2CompositionController(hwnd, &ctrl_handler) { + tracing::error!("[webview/win] CreateCompositionController: {e:?}"); + } + } + Ok(()) + })); + + unsafe { + if let Err(e) = CreateCoreWebView2EnvironmentWithOptions( + windows_wv::core::PCWSTR::null(), + windows_wv::core::PCWSTR::null(), + None, + &env_handler, + ) { + tracing::error!("[webview/win] CreateEnvironmentWithOptions: {e:?}"); + } + } +} + +fn on_ctrl_created( + hr: windows_wv::core::Result<()>, + comp_ctrl: Option, + win_arc: &Arc>, + watchdog: &WatchdogState, + env: &ICoreWebView2Environment, + app: &AppHandle, +) { + if hr.is_err() { + tracing::error!("[webview/win] CompositionController failed: {hr:?}"); + return; + } + let comp_ctrl = match comp_ctrl { + Some(c) => c, + None => { + tracing::error!("[webview/win] CompositionController is None"); + return; + } + }; + + unsafe { + if let Some(vis) = win_arc.lock().unwrap().dcomp_visual.clone() { + let unknown: windows_wv::core::IUnknown = match vis.cast() { + Ok(u) => u, + Err(e) => { + tracing::error!("[webview/win] visual→IUnknown: {e:?}"); + return; + } + }; + if let Err(e) = comp_ctrl.SetRootVisualTarget(&unknown) { + tracing::error!("[webview/win] SetRootVisualTarget: {e:?}"); + return; + } + } + + let controller: ICoreWebView2Controller = match comp_ctrl.cast() { + Ok(c) => c, + Err(e) => { + tracing::error!("[webview/win] comp_ctrl→controller: {e:?}"); + return; + } + }; + + let _ = controller.SetBounds(RECT { + left: 0, + top: 0, + right: 1, + bottom: 1, + }); + let _ = controller.SetIsVisible(false); + + if let Ok(wv) = controller.CoreWebView2() { + crate::policy::register_webview2_resource_handler(&wv, env, app.clone()); + navigate_to_email(&wv); + } + + if let Some(dev) = win_arc.lock().unwrap().dcomp_device.clone() { + let _ = dev.Commit(); + } + + let wd = watchdog.clone(); + let our_pid = std::process::id(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(700)).await; + if let Some(pid) = find_webview2_renderer_pid(our_pid) { + wd.0.lock().unwrap().pid = Some(pid); + tracing::info!(pid, "email renderer PID captured (WebView2/DComp)"); + } else { + tracing::warn!("[webview/win] could not resolve WebView2 renderer PID"); + } + }); + + let mut g = win_arc.lock().unwrap(); + g.controller = Some(controller); + g.comp_ctrl = Some(comp_ctrl); + + if let Some((x, y, w, h)) = g.pending_bounds.take() { + if let (Some(ctrl), Some(vis), Some(dev)) = ( + g.controller.as_ref(), + g.dcomp_visual.as_ref(), + g.dcomp_device.as_ref(), + ) { + let _ = ctrl.SetBounds(RECT { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }); + let _ = ctrl.SetIsVisible(w > 0.0 && h > 0.0); + let _ = vis.SetOffsetX2(x as f32); + let _ = vis.SetOffsetY2(y as f32); + let _ = dev.Commit(); + } + } + } + + tracing::info!("[webview/win] ICoreWebView2CompositionController ready"); +} + +fn update_bounds_on_main(app: &AppHandle, x: f64, y: f64, w: f64, h: f64) { + let state = app.state::(); + let mut guard = state.win.lock().unwrap(); + let (Some(ctrl), Some(vis), Some(dev)) = ( + guard.controller.as_ref(), + guard.dcomp_visual.as_ref(), + guard.dcomp_device.as_ref(), + ) else { + guard.pending_bounds = Some((x, y, w, h)); + return; + }; + + unsafe { + let _ = ctrl.SetBounds(RECT { + left: 0, + top: 0, + right: w as i32, + bottom: h as i32, + }); + let _ = ctrl.SetIsVisible(w > 0.0 && h > 0.0); + let _ = vis.SetOffsetX2(x as f32); + let _ = vis.SetOffsetY2(y as f32); + let _ = dev.Commit(); + } +} + +fn destroy_on_main(app: &AppHandle) { + let state = app.state::(); + let mut guard = state.win.lock().unwrap(); + unsafe { + if let Some(ctrl) = guard.controller.as_ref() { + let _ = ctrl.SetIsVisible(false); + let _ = ctrl.Close(); + } + if let Some(dev) = guard.dcomp_device.as_ref() { + let _ = dev.Commit(); + } + } + guard.comp_ctrl = None; + guard.controller = None; + guard.dcomp_device = None; + guard.dcomp_target = None; + guard.dcomp_visual = None; + tracing::info!("[webview/win] email WebView destroyed (DComp)"); +} + +fn reload_on_main(app: &AppHandle) { + let state = app.state::(); + let guard = state.win.lock().unwrap(); + if let Some(ctrl) = guard.controller.as_ref() { + unsafe { + if let Ok(wv) = ctrl.CoreWebView2() { + navigate_to_email(&wv); + tracing::info!("[webview/win] email WebView reloaded"); + } + } + } +} + +fn setup_dcomp( + hwnd: HWND, +) -> Result< + ( + IDCompositionDevice, + IDCompositionTarget, + IDCompositionVisual, + ), + String, +> { + unsafe { + let mut d3d_device = None; + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_HARDWARE, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut d3d_device), + None, + None, + ) + .map_err(|e| format!("D3D11CreateDevice: {e:?}"))?; + + let d3d = d3d_device.ok_or("D3D11 device is None")?; + let dxgi: IDXGIDevice = d3d.cast().map_err(|e| format!("IDXGIDevice cast: {e:?}"))?; + + let dev: IDCompositionDevice = DCompositionCreateDevice(Some(&dxgi)) + .map_err(|e| format!("DCompositionCreateDevice: {e:?}"))?; + + let target: IDCompositionTarget = dev + .CreateTargetForHwnd(hwnd, true) + .map_err(|e| format!("CreateTargetForHwnd: {e:?}"))?; + + let root: IDCompositionVisual = dev + .CreateVisual() + .map_err(|e| format!("CreateVisual (root): {e:?}"))?; + let email_vis: IDCompositionVisual = dev + .CreateVisual() + .map_err(|e| format!("CreateVisual (email): {e:?}"))?; + + root.AddVisual(&email_vis, false, None) + .map_err(|e| format!("AddVisual: {e:?}"))?; + target + .SetRoot(&root) + .map_err(|e| format!("SetRoot: {e:?}"))?; + dev.Commit().map_err(|e| format!("initial Commit: {e:?}"))?; + + Ok((dev, target, email_vis)) + } +} + +#[inline] +unsafe fn navigate_to_email(wv: &ICoreWebView2) { + let url: Vec = "http://postail.localhost/message/current\0" + .encode_utf16() + .collect(); + let _ = wv.Navigate(windows_wv::core::PCWSTR(url.as_ptr())); +} + +fn find_webview2_renderer_pid(parent_pid: u32) -> Option { + use ::windows::Win32::Foundation::CloseHandle; + use ::windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + unsafe { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = None; + if Process32FirstW(snap, &mut entry).is_ok() { + loop { + if entry.th32ParentProcessID == parent_pid { + let name = String::from_utf16_lossy( + &entry.szExeFile + [..entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(260)], + ); + if name.to_lowercase().contains("msedgewebview2") { + found = Some(entry.th32ProcessID); + break; + } + } + if Process32NextW(snap, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snap); + found + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1217f82..5573ebfc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,7 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["protocol-asset", "tray-icon"] } +tauri = { version = "2", features = [ + "protocol-asset", + "tray-icon", + "unstable", +] } tauri-plugin-opener = "2" tauri-plugin-os = "2" tauri-plugin-dialog = "2" @@ -45,7 +49,11 @@ hkdf = "0.12" hex = "0.4" # Database -rusqlite = { version = "0.38", features = ["bundled-sqlcipher", "unlock_notify", "backup"] } +rusqlite = { version = "0.38", features = [ + "bundled-sqlcipher", + "unlock_notify", + "backup", +] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } zip = { version = "7", default-features = false, features = ["deflate"] } @@ -59,6 +67,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Mail handling html_transpiler = { path = "../crates/html_transpiler" } +email_network = { path = "../crates/email_network" } +email_webview = { path = "../crates/email_webview" } async-imap = "0.11" stop-token = "0.7" mailparse = "0.16" @@ -67,7 +77,9 @@ ammonia = "4" utf7-imap = "0.3.2" regex = "1" async-smtp = "0.10.2" -lettre = { version = "0.11.19", default-features = false, features = ["builder"] } +lettre = { version = "0.11.19", default-features = false, features = [ + "builder", +] } tokio-native-tls = "0.3.1" kuchikiki = "0.8.8-speedreader" markup5ever = "0.14" @@ -82,15 +94,28 @@ icalendar = "0.17.10" # Linux-specific deps [target.'cfg(target_os = "linux")'.dependencies] -nix = { version = "0.29", features = ["process", "user", "socket", "fs", "inotify"] } +nix = { version = "0.29", features = [ + "process", + "signal", + "user", + "socket", + "fs", + "inotify", +] } tauri-plugin-notification = { version = "2.3.3" } -webkit2gtk = { version = "2.0", features = ["v2_32"] } # Windows-specific deps [target.'cfg(target_os = "windows")'.dependencies] -tauri-plugin-notification = { version = "2.3.3", features = ["windows-version"] } +tauri-plugin-notification = { version = "2.3.3", features = [ + "windows-version", +] } dunce = "1.0.5" -windows = { version = "0.62.2", features = ["ApplicationModel_Appointments", "Foundation", "ApplicationModel_Appointments_AppointmentsProvider"] } +windows = { version = "0.62.2", features = [ + "ApplicationModel", + "ApplicationModel_Appointments", + "Foundation", + "Win32_Foundation", +] } [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] tauri-plugin-autostart = "2.5.1" @@ -99,12 +124,5 @@ tauri-plugin-autostart = "2.5.1" tempfile = "3" [features] -default = ["tpm"] +default = [] tpm = ["tss-esapi"] - -[profile.release] -lto = "thin" -codegen-units = 1 -strip = true -opt-level = "s" -panic = "abort" diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs index 09a2f761..1b5b0a4d 100644 --- a/src-tauri/src/calendar/mod.rs +++ b/src-tauri/src/calendar/mod.rs @@ -52,12 +52,16 @@ pub async fn create_calendar_event( #[cfg(target_os = "windows")] async fn list_windows_events(start: i64, end: i64) -> Result, String> { - use windows::ApplicationModel::Appointments::{ - AppointmentManager, AppointmentStore, AppointmentStoreAccessType, - }; - use windows::Foundation::DateTime as WinDateTime; + tokio::task::spawn_blocking(move || list_windows_events_sync(start, end)) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(target_os = "windows")] +fn list_windows_events_sync(start: i64, end: i64) -> Result, String> { + use windows::ApplicationModel::Appointments::{AppointmentManager, AppointmentStoreAccessType}; + use windows::Foundation::{DateTime as WinDateTime, TimeSpan}; - // Convert Unix timestamps to WinRT DateTime (100-nanosecond intervals since Jan 1, 1601) let win_start = WinDateTime { UniversalTime: (start * 10_000_000) + 116_444_736_000_000_000, }; @@ -65,61 +69,70 @@ async fn list_windows_events(start: i64, end: i64) -> Result, UniversalTime: (end * 10_000_000) + 116_444_736_000_000_000, }; - let store = - AppointmentManager::RequestStoreAsync(AppointmentStoreAccessType::AllCalendarsReadOnly) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + + rt.block_on(async move { + let store = + AppointmentManager::RequestStoreAsync(AppointmentStoreAccessType::AllCalendarsReadOnly) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + let appointments = store + .FindAppointmentsAsync( + win_start, + TimeSpan { + Duration: (win_end.UniversalTime - win_start.UniversalTime) as i64, + }, + ) .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; - let appointments = store - .FindAppointmentsAsync( - win_start, - (win_end.UniversalTime - win_start.UniversalTime) as i64, - ) - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; - - let mut events = Vec::new(); - for appointment in appointments { - let start_time = (appointment - .StartTime() - .map_err(|e| e.to_string())? - .UniversalTime - - 116_444_736_000_000_000) - / 10_000_000; - let duration = appointment.Duration().map_err(|e| e.to_string())?.Duration / 10_000_000; - - events.push(CalendarEvent { - id: appointment - .LocalId() + let mut events = Vec::new(); + for appointment in appointments { + let start_time = (appointment + .StartTime() .map_err(|e| e.to_string())? - .to_string(), - title: appointment - .Subject() - .map_err(|e| e.to_string())? - .to_string(), - description: Some( - appointment - .Details() + .UniversalTime + - 116_444_736_000_000_000) + / 10_000_000; + let duration = appointment.Duration().map_err(|e| e.to_string())?.Duration / 10_000_000; + + events.push(CalendarEvent { + id: appointment + .LocalId() .map_err(|e| e.to_string())? .to_string(), - ), - location: Some( - appointment - .Location() + title: appointment + .Subject() .map_err(|e| e.to_string())? .to_string(), - ), - start: start_time, - end: start_time + duration, - is_all_day: appointment.AllDay().map_err(|e| e.to_string())?, - calendar_name: "Windows Calendar".to_string(), - color: None, - }); - } + description: Some( + appointment + .Details() + .map_err(|e| e.to_string())? + .to_string(), + ), + location: Some( + appointment + .Location() + .map_err(|e| e.to_string())? + .to_string(), + ), + start: start_time, + end: start_time + duration, + is_all_day: appointment.AllDay().map_err(|e| e.to_string())?, + calendar_name: "Windows Calendar".to_string(), + color: None, + }); + } - Ok(events) + Ok(events) + }) } #[cfg(target_os = "linux")] @@ -226,6 +239,22 @@ async fn create_windows_event( start: i64, end: i64, is_all_day: bool, +) -> Result { + tokio::task::spawn_blocking(move || { + create_windows_event_sync(title, description, location, start, end, is_all_day) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(target_os = "windows")] +fn create_windows_event_sync( + title: String, + description: Option, + location: Option, + start: i64, + end: i64, + is_all_day: bool, ) -> Result { use windows::ApplicationModel::Appointments::{ Appointment, AppointmentManager, AppointmentStoreAccessType, @@ -233,53 +262,76 @@ async fn create_windows_event( use windows::Foundation::DateTime as WinDateTime; use windows::Foundation::TimeSpan; - let appointment = Appointment::new().map_err(|e| e.to_string())?; - appointment - .SetSubject(&windows::core::HSTRING::from(title)) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .map_err(|e| e.to_string())?; - if let Some(desc) = description { + + rt.block_on(async move { + let appointment = Appointment::new().map_err(|e| e.to_string())?; appointment - .SetDetails(&windows::core::HSTRING::from(desc)) + .SetSubject(&windows::core::HSTRING::from(title)) .map_err(|e| e.to_string())?; - } - if let Some(loc) = location { + if let Some(desc) = description { + appointment + .SetDetails(&windows::core::HSTRING::from(desc)) + .map_err(|e| e.to_string())?; + } + if let Some(loc) = location { + appointment + .SetLocation(&windows::core::HSTRING::from(loc)) + .map_err(|e| e.to_string())?; + } + + let win_start = WinDateTime { + UniversalTime: (start * 10_000_000) + 116_444_736_000_000_000, + }; appointment - .SetLocation(&windows::core::HSTRING::from(loc)) + .SetStartTime(win_start) + .map_err(|e| e.to_string())?; + appointment + .SetDuration(TimeSpan { + Duration: (end - start) * 10_000_000, + }) + .map_err(|e| e.to_string())?; + appointment + .SetAllDay(is_all_day) .map_err(|e| e.to_string())?; - } - - let win_start = WinDateTime { - UniversalTime: (start * 10_000_000) + 116_444_736_000_000_000, - }; - appointment - .SetStartTime(win_start) - .map_err(|e| e.to_string())?; - appointment - .SetDuration(TimeSpan { - Duration: (end - start) * 10_000_000, - }) - .map_err(|e| e.to_string())?; - appointment - .SetAllDay(is_all_day) - .map_err(|e| e.to_string())?; - - let store = AppointmentManager::RequestStoreAsync( - AppointmentStoreAccessType::AllCalendarsWriteReadOnly, - ) - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; - store - .SaveAppointmentAsync(&appointment) + let store = AppointmentManager::RequestStoreAsync( + AppointmentStoreAccessType::AppCalendarsReadWrite, + ) .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; - Ok(appointment - .LocalId() - .map_err(|e| e.to_string())? - .to_string()) + let calendars = store + .FindAppointmentCalendarsAsync() + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + let calendar = if calendars.Size().map_err(|e| e.to_string())? > 0 { + calendars.GetAt(0).map_err(|e| e.to_string())? + } else { + store + .CreateAppointmentCalendarAsync(&windows::core::HSTRING::from("Postail")) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())? + }; + + calendar + .SaveAppointmentAsync(&appointment) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + Ok(appointment + .LocalId() + .map_err(|e| e.to_string())? + .to_string()) + }) } #[cfg(target_os = "linux")] diff --git a/src-tauri/src/cmd/mail/listing/fetching.rs b/src-tauri/src/cmd/mail/listing/fetching.rs index 4589bbb7..9db080c2 100644 --- a/src-tauri/src/cmd/mail/listing/fetching.rs +++ b/src-tauri/src/cmd/mail/listing/fetching.rs @@ -1,45 +1,57 @@ -use tauri::command; -use crate::globals::get_db_pool; -use crate::db::MessageFull; +use crate::db::mail::eml_cache; +use crate::db::{MessageMeta, ThreadMessageMeta, ThreadViewMeta}; +use crate::globals::{get_crypto, get_db_pool}; use rusqlite::OptionalExtension; +use tauri::command; #[command] pub async fn fetch_message_full( account_id: String, mailbox: String, uid: u32, -) -> Result { +) -> Result { let pool = get_db_pool().await.map_err(|e| e.to_string())?; let conn = pool.get().map_err(|e| e.to_string())?; - let mut message = crate::db::mail::messages::fetch_message_full(&conn, &account_id, &mailbox, uid) - .map_err(|e| e.to_string())? - .ok_or_else(|| "Message not found in database".to_string())?; + let mut message = + crate::db::mail::messages::fetch_message_full(&conn, &account_id, &mailbox, uid) + .map_err(|e| e.to_string())? + .ok_or_else(|| "Message not found in database".to_string())?; - // Load body content from disk - if let Ok(Some(message_table_id)) = - crate::db::mail::messages::get_message_table_id(&conn, &account_id, &mailbox, uid) - { - if let Ok((html, plain)) = - crate::db::mail::message_bodies::load_message_body(&conn, message_table_id) - { - message.body_html_safe = html.unwrap_or_default(); - message.body_plain = plain; + let crypto = get_crypto().await?; + match eml_cache::load_body(&crypto, &account_id, &mailbox, uid) { + Ok(Some(cached)) => { + message.read_receipt_to = cached.read_receipt_to; + } + _ => { + // Cache miss — pull from IMAP and cache + let imap = crate::globals::IMAP_MANAGER.lock().await; + if let Ok(Some(full)) = imap + .fetch_and_cache_message(&account_id, &mailbox, uid) + .await + { + message.read_receipt_to = full.read_receipt_to; + } } } - Ok(message) + Ok(message.into()) } -fn fetch_thread_uids(conn: &rusqlite::Connection, account_id: &str, mailbox: &str, uid: u32) - -> Result, crate::error::DBError> -{ +fn fetch_thread_uids( + conn: &rusqlite::Connection, + account_id: &str, + mailbox: &str, + uid: u32, +) -> Result, crate::error::DBError> { // Look up the message_id for this uid to find the thread - let message_id: Option = conn.query_row( - "SELECT message_id FROM messages WHERE account_id = ? AND mailbox = ? AND uid = ?", - rusqlite::params![account_id, mailbox, uid], - |row| row.get(0), - ).optional()?; + let message_id: Option = conn + .query_row( + "SELECT message_id FROM messages WHERE account_id = ? AND mailbox = ? AND uid = ?", + rusqlite::params![account_id, mailbox, uid], + |row| row.get(0), + ) + .optional()?; let Some(message_id) = message_id else { return Ok(vec![]); @@ -48,10 +60,9 @@ fn fetch_thread_uids(conn: &rusqlite::Connection, account_id: &str, mailbox: &st let mut stmt = conn.prepare( "SELECT uid FROM messages WHERE account_id = ? AND mailbox = ? AND message_id = ? ORDER BY uid ASC" )?; - let uids_iter = stmt.query_map( - rusqlite::params![account_id, mailbox, message_id], - |row| row.get(0), - )?; + let uids_iter = stmt.query_map(rusqlite::params![account_id, mailbox, message_id], |row| { + row.get(0) + })?; let uids: Result, _> = uids_iter.collect(); Ok(uids?) } @@ -61,33 +72,27 @@ pub async fn fetch_thread( account_id: String, mailbox: String, uid: u32, -) -> Result, String> { +) -> Result { let pool = get_db_pool().await.map_err(|e| e.to_string())?; let conn = pool.get().map_err(|e| e.to_string())?; - let thread_uids = fetch_thread_uids(&conn, &account_id, &mailbox, uid) - .map_err(|e| e.to_string())?; + let thread_uids = + fetch_thread_uids(&conn, &account_id, &mailbox, uid).map_err(|e| e.to_string())?; - let mut messages = Vec::new(); + // Return only headers + let mut messages: Vec = Vec::new(); for t_uid in thread_uids { - if let Ok(Some(mut msg)) = + if let Ok(Some(msg)) = crate::db::mail::messages::fetch_message_full(&conn, &account_id, &mailbox, t_uid) { - if let Ok(Some(message_table_id)) = - crate::db::mail::messages::get_message_table_id(&conn, &account_id, &mailbox, t_uid) - { - if let Ok((html, plain)) = - crate::db::mail::message_bodies::load_message_body(&conn, message_table_id) - { - msg.body_html_safe = html.unwrap_or_default(); - msg.body_plain = plain; - } - } - messages.push(msg); + messages.push(ThreadMessageMeta { + header: msg.header, + is_current: t_uid == uid, + }); } } - Ok(messages) + Ok(ThreadViewMeta { messages }) } #[command] diff --git a/src-tauri/src/cmd/network.rs b/src-tauri/src/cmd/network.rs index cbc7f11a..4b144d0c 100644 --- a/src-tauri/src/cmd/network.rs +++ b/src-tauri/src/cmd/network.rs @@ -1,4 +1,4 @@ -use crate::network::cache::RESOURCE_CACHE; +use email_network::network::cache::RESOURCE_CACHE; use serde::Serialize; use tauri::command; diff --git a/src-tauri/src/cmd/security/mod.rs b/src-tauri/src/cmd/security/mod.rs index 99774ab1..d3dc70c0 100644 --- a/src-tauri/src/cmd/security/mod.rs +++ b/src-tauri/src/cmd/security/mod.rs @@ -1,23 +1,22 @@ -pub mod options; pub mod lock; +pub mod options; pub mod recovery; -pub use options::*; pub use lock::*; +pub use options::*; pub use recovery::*; -use crate::globals::{get_db_pool, DB_CONN, SECURITY, SMTP_MANAGER, CRYPTO_ACTOR}; +use crate::globals::{CRYPTO_ACTOR, DB_CONN, SECURITY, SMTP_MANAGER, get_db_pool}; use crate::security::manager::PassphraseSecurityBuilder; use crate::security::recovery::RecoveryStore; -use crate::security::storage::keyring::KeyringStore; use crate::security::storage::StorageTier; +use crate::security::storage::keyring::KeyringStore; use crate::security::tpm::store::get_tpm_store; use crate::security::{DbEncryption, SecurityManager}; -use crate::utils::config::{load_config, save_config, AppConfig}; +use crate::utils::config::{AppConfig, load_config, save_config}; use serde::Serialize; use std::sync::Arc; use tauri::command; -use tokio::task::spawn_blocking; #[derive(Serialize)] pub struct InitStatus { @@ -172,11 +171,11 @@ pub async fn initialize_security_and_database( #[cfg(all(target_os = "linux", feature = "tpm"))] async fn initialize_tpm_elevated() -> Result<(), options::TpmInitError> { - use std::time::Duration; - use tokio::process::Command; use nix::sys::inotify::{AddWatchFlags, InitFlags, Inotify}; use std::os::unix::io::{AsRawFd, RawFd}; + use std::time::Duration; use tokio::io::unix::AsyncFd; + use tokio::process::Command; // Check if we're already running as helper mode to avoid infinite loops if std::env::var("POSTAIL_TPM_HELPER").is_ok() { @@ -216,7 +215,9 @@ async fn initialize_tpm_elevated() -> Result<(), options::TpmInitError> { error_type: options::TpmErrorType::Other, message: format!("Failed to get executable path: {}", e), })? - }.to_string_lossy().to_string(); + } + .to_string_lossy() + .to_string(); tracing::info!(target: "postail", "Requesting TPM elevation via pkexec (persistent helper)..."); @@ -249,11 +250,12 @@ async fn initialize_tpm_elevated() -> Result<(), options::TpmInitError> { error_type: options::TpmErrorType::Other, message: format!("inotify add_watch failed: {}", e), })?; - let async_inotify = - AsyncFd::new(InotifyWrapper(inotify)).map_err(|e: std::io::Error| options::TpmInitError { + let async_inotify = AsyncFd::new(InotifyWrapper(inotify)).map_err(|e: std::io::Error| { + options::TpmInitError { error_type: options::TpmErrorType::Other, message: format!("AsyncFd wrapper failed: {}", e), - })?; + } + })?; let (tx, mut rx) = tokio::sync::oneshot::channel::(); diff --git a/src-tauri/src/cmd/security/options.rs b/src-tauri/src/cmd/security/options.rs index d7f47994..0dea562b 100644 --- a/src-tauri/src/cmd/security/options.rs +++ b/src-tauri/src/cmd/security/options.rs @@ -1,9 +1,10 @@ -use std::time::Duration; -use tauri::command; -use tokio::task::spawn_blocking; -use tokio::time::timeout; +use crate::security::storage::{SecretStore, keyring::KeyringStore}; use serde::Serialize; -use crate::security::storage::{keyring::KeyringStore, SecretStore}; +use tauri::command; + +// --------------------------------------------------------------------------- +// TPM error types (Linux + tpm feature only) +// --------------------------------------------------------------------------- #[cfg(all(target_os = "linux", feature = "tpm"))] #[derive(Serialize, Debug, Clone, Copy)] @@ -38,6 +39,50 @@ impl From for String { } } +// --------------------------------------------------------------------------- +// TpmStatus — the serializable result exposed to the frontend +// --------------------------------------------------------------------------- + +#[cfg(all(target_os = "linux", feature = "tpm"))] +#[derive(Serialize, Clone, Copy)] +pub enum TpmStatus { + Available, + RequiresElevation, + NotAvailable, +} + +#[cfg(all(target_os = "linux", feature = "tpm"))] +impl From for TpmStatus { + fn from(availability: crate::security::TpmAvailability) -> Self { + match availability { + crate::security::TpmAvailability::Available => TpmStatus::Available, + crate::security::TpmAvailability::RequiresElevation => TpmStatus::RequiresElevation, + crate::security::TpmAvailability::NotAvailable => TpmStatus::NotAvailable, + } + } +} + +// --------------------------------------------------------------------------- +// Shared helper — runs TpmInitializer on a blocking thread with a 3 s cap. +// Falls back to NotAvailable on timeout or join error. +// --------------------------------------------------------------------------- + +#[cfg(all(target_os = "linux", feature = "tpm"))] +async fn tpm_check() -> TpmStatus { + timeout( + Duration::from_secs(3), + spawn_blocking(|| crate::security::TpmInitializer::new().check_availability()), + ) + .await + .unwrap_or(Ok(crate::security::TpmAvailability::NotAvailable)) + .unwrap_or(crate::security::TpmAvailability::NotAvailable) + .into() +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + #[derive(Serialize)] pub struct SecurityOptions { #[cfg(all(target_os = "linux", feature = "tpm"))] @@ -51,22 +96,11 @@ pub struct SecurityOptions { #[command] pub async fn check_security_options() -> Result { #[cfg(all(target_os = "linux", feature = "tpm"))] - let (tpm_available, tpm_requires_elevation) = timeout( - Duration::from_secs(3), - spawn_blocking(|| { - use crate::security::TpmInitializer; - let initializer = TpmInitializer::new(); - let availability = initializer.check_availability(); - match availability { - crate::security::TpmAvailability::Available => (true, false), - crate::security::TpmAvailability::RequiresElevation => (true, true), - crate::security::TpmAvailability::NotAvailable => (false, false), - } - }), - ) - .await - .unwrap_or(Ok((false, false))) - .unwrap_or((false, false)); + let (tpm_available, tpm_requires_elevation) = match tpm_check().await { + TpmStatus::Available => (true, false), + TpmStatus::RequiresElevation => (true, true), + TpmStatus::NotAvailable => (false, false), + }; let keyring_available = KeyringStore::new() .map(|k| k.is_available()) @@ -82,33 +116,8 @@ pub async fn check_security_options() -> Result { }) } -#[cfg(all(target_os = "linux", feature = "tpm"))] -#[derive(Serialize)] -pub enum TpmStatus { - Available, - RequiresElevation, - NotAvailable, -} - #[cfg(all(target_os = "linux", feature = "tpm"))] #[command] pub async fn check_tpm_availability() -> Result { - use crate::security::TpmAvailability; - - let result = timeout( - Duration::from_secs(3), - spawn_blocking(|| { - let initializer = crate::security::TpmInitializer::new(); - initializer.check_availability() - }), - ) - .await - .unwrap_or(Ok(TpmAvailability::NotAvailable)) - .unwrap_or(TpmAvailability::NotAvailable); - - match result { - TpmAvailability::Available => Ok(TpmStatus::Available), - TpmAvailability::RequiresElevation => Ok(TpmStatus::RequiresElevation), - TpmAvailability::NotAvailable => Ok(TpmStatus::NotAvailable), - } + Ok(tpm_check().await) } diff --git a/src-tauri/src/cmd/utils.rs b/src-tauri/src/cmd/utils.rs index 3cfbd8bb..7a608679 100644 --- a/src-tauri/src/cmd/utils.rs +++ b/src-tauri/src/cmd/utils.rs @@ -1,6 +1,6 @@ use crate::email_view::EmailViewState; use crate::globals::SECURITY; -use crate::network::rewriter::rewrite_external_resources; +use email_network::network::rewriter::rewrite_external_resources; use base64::{Engine as _, engine::general_purpose::STANDARD}; use html_transpiler::{ SanitizeResult, auto_fix_email_html as sanitizer_fix, sanitize_email_html_with_details, @@ -108,3 +108,29 @@ pub async fn set_email_view_content( processed_html: processed, }) } + +/// Shows a native OS confirmation dialog asking the user whether to open an external link. +#[tauri::command] +pub async fn confirm_external_link( + app: tauri::AppHandle, + url: String, + title: String, + body: String, + ok_label: String, + cancel_label: String, +) -> Result<(), String> { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; + + app.dialog() + .message(body) + .title(title) + .buttons(MessageDialogButtons::OkCancelCustom(ok_label, cancel_label)) + .show(move |confirmed| { + if confirmed { + use tauri_plugin_opener::OpenerExt; + let _ = app.opener().open_url(&url, None::<&str>); + } + }); + + Ok(()) +} diff --git a/src-tauri/src/db/mail/message_bodies.rs b/src-tauri/src/db/mail/message_bodies.rs index e4c502df..8800117b 100644 --- a/src-tauri/src/db/mail/message_bodies.rs +++ b/src-tauri/src/db/mail/message_bodies.rs @@ -1,7 +1,7 @@ use crate::error::DBError; use ammonia; -use mailparse::{parse_mail, ParsedMail}; -use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult}; +use mailparse::{ParsedMail, parse_mail}; +use rusqlite::{Connection, OptionalExtension, Result as SqlResult, params}; use std::panic; type MessageBodyFull = (Option, String, Option>, Option); @@ -84,7 +84,7 @@ pub fn save_message_body( body_html: Option<&str>, body_plain: Option<&str>, ) -> Result<(), DBError> { - let body_html_safe = body_html.map(ammonia::clean).unwrap_or_default(); + let body_html_safe = body_html.unwrap_or_default(); let body_text = body_plain.unwrap_or_else(|| body_html.unwrap_or("")); let snippet = body_text.chars().take(200).collect::(); diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 93f91ffc..d1901790 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -273,6 +273,52 @@ pub struct MessageFull { pub read_receipt_to: Option, } +/// Frontend-safe view of a message +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageMeta { + pub header: MailHeader, + pub attachments: Vec, + pub inline_images: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_receipt_to: Option, +} + +impl From for MessageMeta { + fn from(m: MessageFull) -> Self { + Self { + header: m.header, + attachments: m.attachments, + inline_images: m.inline_images, + read_receipt_to: m.read_receipt_to, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ThreadMessage { + pub header: crate::db::MailHeader, + pub body_html_safe: String, + pub body_plain: String, + pub is_current: bool, +} + +/// Frontend-safe thread message — no body content. +#[derive(Debug, Serialize, Deserialize)] +pub struct ThreadMessageMeta { + pub header: crate::db::MailHeader, + pub is_current: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ThreadView { + pub messages: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ThreadViewMeta { + pub messages: Vec, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OutboxItem { diff --git a/src-tauri/src/email_view.rs b/src-tauri/src/email_view.rs index 32025902..c68f9f0a 100644 --- a/src-tauri/src/email_view.rs +++ b/src-tauri/src/email_view.rs @@ -1,4 +1,10 @@ +use crate::db::mail::eml_cache; +use crate::globals::{IMAP_MANAGER, SECURITY, get_crypto, get_db_pool}; +use email_network::network::rewriter::rewrite_external_resources; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Serialize; use std::sync::Mutex; +use tauri::{State, command}; pub struct EmailViewState { pub html: Mutex>, @@ -13,3 +19,203 @@ impl Default for EmailViewState { } } } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrepareEmailViewResult { + pub has_external_resources: bool, + pub is_plain_only: bool, +} + +/// Builds the full HTML document that the webview will load +fn build_email_html( + body_html_safe: &str, + body_plain: &str, + accent_color: &str, + view_mode: &str, +) -> (String, bool) { + let is_plain_only = body_html_safe.trim().is_empty() || view_mode == "plain"; + + if is_plain_only { + let escaped = escape_html(body_plain); + let html = format!( + r#" + + + + + + +
{escaped}
+ +"#, + escaped = escaped + ); + return (html, true); + } + + let has_dark = body_html_safe.contains("prefers-color-scheme: dark") + || body_html_safe.contains("data-ogsc") + || body_html_safe.contains("data-ogsb"); + + let iframe_bg = if has_dark { "transparent" } else { "#ffffff" }; + let iframe_text = if has_dark { "inherit" } else { "#1a1a1a" }; + let color_scheme = if has_dark { "dark light" } else { "light" }; + + let html = format!( + r#" + + + + + + +
{body_html_safe}
+ + +"#, + iframe_text = iframe_text, + iframe_bg = iframe_bg, + color_scheme = color_scheme, + accent_color = accent_color, + body_html_safe = body_html_safe, + ); + + (html, false) +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Fetches the email body from cache/IMAP, builds the full HTML document, processes inline images and external resources, and stores the result in EmailViewState for the protocol handler to serve +#[command] +pub async fn prepare_email_view( + state: State<'_, EmailViewState>, + account_id: String, + mailbox: String, + uid: u32, + accent_color: String, + allow_external: bool, + view_mode: String, +) -> Result { + let pool = get_db_pool().await.map_err(|e| e.to_string())?; + let conn = pool.get().map_err(|e| e.to_string())?; + + // Load message metadata from DB to get inline image attachment info + let msg_meta = crate::db::mail::messages::fetch_message_full(&conn, &account_id, &mailbox, uid) + .map_err(|e| e.to_string())? + .ok_or_else(|| "Message not found".to_string())?; + + // Fetch body from encrypted file cache, fall back to IMAP fetch+cache + let crypto = get_crypto().await?; + let (body_html, body_plain) = match eml_cache::load_body(&crypto, &account_id, &mailbox, uid) { + Ok(Some(cached)) => (cached.body_html, cached.body_plain), + _ => { + let imap = IMAP_MANAGER.lock().await; + if let Ok(Some(full)) = imap + .fetch_and_cache_message(&account_id, &mailbox, uid) + .await + { + (full.body_html_safe, full.body_plain) + } else { + (String::new(), String::new()) + } + } + }; + + // Build the full HTML document — Rust owns the email content, not the frontend + let (mut html, is_plain_only) = + build_email_html(&body_html, &body_plain, &accent_color, &view_mode); + + // Resolve inline images: replace cid: references with encrypted-and-decrypted data: URLs + let inline_images = msg_meta.inline_images; + if !inline_images.is_empty() { + let security = SECURITY.lock().await; + for img in &inline_images { + let cid = match &img.cid { + Some(c) if !c.is_empty() => c, + _ => continue, + }; + let cached_path = match &img.cached_path { + Some(p) if !p.is_empty() => p, + _ => continue, + }; + + let raw_cid = cid.trim_matches(|c| c == '<' || c == '>'); + + let data = match std::fs::read(cached_path) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + let decrypted = match security.decrypt(&data) { + Ok(bytes) => bytes, + Err(_) => continue, + }; + + let b64 = STANDARD.encode(&decrypted); + let data_url = format!("data:{};base64,{}", img.mime_type, b64); + let cid_pattern = format!("cid:{}", raw_cid); + html = html.replace(&cid_pattern, &data_url); + } + } + + // Rewrite or detect external resources + let rewrite = rewrite_external_resources(&html, allow_external).await; + html = rewrite.html; + + // Store in state — protocol.rs will serve this on the next webview load + *state.html.lock().map_err(|e| e.to_string())? = Some(html); + *state.allow_external.lock().map_err(|e| e.to_string())? = allow_external; + + Ok(PrepareEmailViewResult { + has_external_resources: rewrite.has_external, + is_plain_only, + }) +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index ea812c36..3249bfa1 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,3 +1,4 @@ +pub use email_network::error::{CacheError, NetworkError}; use thiserror::Error; #[derive(Debug, Error)] @@ -308,44 +309,4 @@ impl From for String { } } -#[derive(Debug, Error)] -pub enum NetworkError { - #[error("request failed: {0}")] - Request(#[from] reqwest::Error), - - #[error("server returned {status} for {url}")] - BadStatus { status: u16, url: String }, - - #[error("resource at {url} exceeds size limit ({size} bytes)")] - TooLarge { url: String, size: usize }, -} - -impl From for String { - fn from(err: NetworkError) -> Self { - err.to_string() - } -} - -#[derive(Debug, Error)] -pub enum CacheError { - #[error("encryption failed: {0}")] - Encryption(String), - #[error("decryption failed: {0}")] - Decryption(String), - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("security manager unavailable: {0}")] - SecurityUnavailable(String), - - #[error("metadata serialization failed: {0}")] - Metadata(String), -} - -impl From for String { - fn from(err: CacheError) -> Self { - err.to_string() - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3bde8267..e6d1a91c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,20 +7,17 @@ pub mod error; pub mod globals; pub mod imap; pub mod maintenance; -pub mod network; pub mod oauth; pub mod protocol; pub mod security; pub mod smtp; pub mod utils; -pub mod webview_policy; use std::sync::atomic::Ordering; use crate::globals::SMTP_MANAGER; use crate::imap::pool::init_pool; use crate::imap::sync_status::set_sync_status_app_handle; -use crate::network::cache::{RESOURCE_CACHE, ResourceCache}; use tauri::Manager; /// TPM helper mode: Initialize TPM with elevated privileges (Linux only) @@ -42,6 +39,8 @@ pub fn run() { tauri::Builder::default() .manage(email_view::EmailViewState::default()) + .manage(email_webview::state::EmbeddedEmailState::default()) + .manage(email_webview::watchdog::WatchdogState::default()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) @@ -60,8 +59,27 @@ pub fn run() { .app_data_dir() .expect("failed to resolve app data dir") .join("resource_cache"); + + use email_network::network::cache::{RESOURCE_CACHE, ResourceCache}; + use std::sync::Arc; + + let sec = crate::globals::SECURITY.clone(); + let encrypt = Arc::new(move |data: &[u8]| { + sec.try_lock() + .map_err(|e| format!("encryption lock: {e}"))? + .encrypt(data) + .map_err(|e| e.to_string()) + }); + let sec = crate::globals::SECURITY.clone(); + let decrypt = Arc::new(move |data: &[u8]| { + sec.try_lock() + .map_err(|e| format!("decryption lock: {e}"))? + .decrypt(data) + .map_err(|e| e.to_string()) + }); + RESOURCE_CACHE - .set(ResourceCache::new(cache_dir)) + .set(ResourceCache::new(cache_dir, encrypt, decrypt)) .unwrap_or_else(|_| tracing::warn!("resource cache already initialized")); } @@ -106,6 +124,13 @@ pub fn run() { crate::security::start_lock_timer(timer_handle).await; }); + // Watchdog — monitors email webview heartbeat (27.10) + let watchdog_handle = handle.clone(); + let watchdog_state = app.state::().inner().clone(); + tauri::async_runtime::spawn(async move { + email_webview::watchdog::run_watchdog_loop(watchdog_handle, watchdog_state).await; + }); + // Initialize IMAP connection pool tauri::async_runtime::spawn(async move { init_pool().await; @@ -118,11 +143,23 @@ pub fn run() { let proxy_port: u16 = portpicker::pick_unused_port().unwrap_or(18731); tauri::async_runtime::spawn(async move { - crate::network::null_proxy::start_on_port(proxy_port).await; + email_network::network::null_proxy::start_on_port(proxy_port).await; }); if let Some(main_window) = app.get_webview_window("main") { - webview_policy::install_network_block(&main_window, proxy_port); + email_webview::policy::install_network_block(&main_window, proxy_port); + } + + // Register route handler for Windows WebView2 resource requests + #[cfg(target_os = "windows")] + { + use std::sync::Arc; + let app_for_route = handle.clone(); + email_webview::policy::set_route_handler(Arc::new( + move |path: &str, query: &str| { + crate::protocol::handle_message_route(&app_for_route, path, query) + }, + )); } Ok(()) @@ -197,6 +234,11 @@ pub fn run() { cmd::mail::sync::unwatch_all_mailboxes, cmd::maintenance::clear_cache, cmd::maintenance::backfill_snippets, + email_webview::commands::create_email_webview, + email_webview::commands::update_email_webview_bounds, + email_webview::commands::destroy_email_webview, + email_webview::commands::reload_email_webview, + email_webview::commands::resume_email_webview, cmd::settings::get_autostart_enabled, cmd::settings::set_autostart_enabled, cmd::maintenance::dev_reset_data, @@ -249,7 +291,8 @@ pub fn run() { cmd::utils::show_notification, cmd::utils::show_main_window, cmd::utils::auto_fix_email_html, - cmd::utils::set_email_view_content, + cmd::utils::confirm_external_link, + email_view::prepare_email_view, cmd::settings::get_all_settings, cmd::settings::get_setting, cmd::settings::set_setting, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 441d722c..e3838840 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,7 +4,7 @@ #[cfg(target_os = "linux")] use std::env; #[cfg(target_os = "linux")] -use std::process::{exit, Command}; +use std::process::{Command, exit}; #[cfg(target_os = "linux")] fn get_executable_path() -> std::path::PathBuf { @@ -44,6 +44,7 @@ fn main() { #[cfg(target_os = "linux")] { // ── TPM helper mode ────────────────────────────────────────── + #[cfg(feature = "tpm")] if env::var("POSTAIL_TPM_HELPER").is_ok() { match postail_project_lib::tpm_helper_init() { Ok(()) => { diff --git a/src-tauri/src/protocol.rs b/src-tauri/src/protocol.rs index 3e121726..8260a239 100644 --- a/src-tauri/src/protocol.rs +++ b/src-tauri/src/protocol.rs @@ -1,9 +1,11 @@ +use email_webview::watchdog::WatchdogState; use crate::email_view::EmailViewState; use serde_json; use std::borrow::Cow; +use std::time::Instant; use tauri::{ + AppHandle, Emitter, Manager, Runtime, UriSchemeContext, http::{Request, Response}, - Emitter, Manager, Runtime, UriSchemeContext, }; pub fn handler( @@ -12,8 +14,12 @@ pub fn handler( ) -> Response> { let path = request.uri().path(); - if path == "/message/current" { - return serve_email(&context); + if let Some(response) = handle_message_route( + context.app_handle(), + path, + request.uri().query().unwrap_or(""), + ) { + return response; } if path == "/window/maximize" { @@ -45,22 +51,126 @@ pub fn handler( .expect("Failed to create response") } -fn serve_email(context: &UriSchemeContext) -> Response> { - let state = context.app_handle().state::(); +pub fn handle_message_route( + app: &AppHandle, + path: &str, + query: &str, +) -> Option>> { + match path { + "/message/current" => Some(serve_email(app)), + "/message/heartbeat" => Some(handle_heartbeat(app, query)), + "/message/link" => Some(handle_link_click(app, query)), + _ => None, + } +} + +fn handle_heartbeat(app: &AppHandle, query: &str) -> Response> { + let state = app.state::(); + + let mut token = ""; + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + if k == "token" { + token = v; + break; + } + } + } + + match email_webview::watchdog::email_heartbeat(&state, token) { + Ok(next) => Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(Cow::Owned( + serde_json::json!({ "token": next }) + .to_string() + .into_bytes(), + )) + .unwrap(), + Err(_) => Response::builder() + .status(200) + .header("Content-Type", "application/json") + .body(Cow::Borrowed(b"{}" as &[u8])) + .unwrap(), + } +} + +fn handle_link_click(app: &AppHandle, query: &str) -> Response> { + let url = url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == "url") + .map(|(_, v)| v.into_owned()); + + let Some(url) = url else { + return Response::builder() + .status(400) + .header("Content-Type", "text/plain") + .body(Cow::Borrowed(b"Missing url" as &[u8])) + .unwrap(); + }; - let html = state + let _ = app.emit("email_link_clicked", serde_json::json!({ "url": url })); + + Response::builder() + .status(200) + .header("Content-Type", "text/plain") + .body(Cow::Borrowed(b"OK" as &[u8])) + .unwrap() +} + +fn serve_email(app: &AppHandle) -> Response> { + let html = app + .state::() .html .lock() .ok() .and_then(|g| g.clone()) .unwrap_or_default(); + // A new token is minted on every serve so stale heartbeats from a previous email load are automatically invalidated + let token = uuid::Uuid::new_v4().to_string(); + + { + let watchdog = app.state::(); + let mut data = watchdog.0.lock().unwrap(); + data.token = token.clone(); + data.last_heartbeat = Instant::now(); + data.created_at = Instant::now(); + data.is_frozen = false; + } + + // Inject the heartbeat loop as the very first script in the document + let script = format!( + r#""#, + token_json = serde_json::to_string(&token).unwrap_or_default(), + ); + + // Inject before when present; fall back to prepending to the document so it always executes even on bare / malformed HTML + let html = if let Some(pos) = html.to_lowercase().find("") { + let mut out = String::with_capacity(html.len() + script.len()); + out.push_str(&html[..pos]); + out.push_str(&script); + out.push_str(&html[pos..]); + out + } else { + script + &html + }; + let csp = "default-src 'none'; \ script-src 'unsafe-inline'; \ style-src 'unsafe-inline' data:; \ img-src data: cid: asset: postail: http://postail.localhost; \ font-src data: asset:; \ - connect-src 'none';"; + connect-src 'self' postail: http://postail.localhost;"; Response::builder() .status(200) diff --git a/src-tauri/src/security/tpm/store/linux.rs b/src-tauri/src/security/tpm/store/linux.rs index df4cc58a..03c810e8 100644 --- a/src-tauri/src/security/tpm/store/linux.rs +++ b/src-tauri/src/security/tpm/store/linux.rs @@ -1,11 +1,8 @@ -#[cfg(feature = "tpm")] -use tss_esapi::{tcti_ldr::TctiNameConf, Context}; - -#[cfg(feature = "tpm")] -use std::str::FromStr; +use tss_esapi::{Context, tcti_ldr::TctiNameConf}; use std::fs; use std::path::PathBuf; +use std::str::FromStr; use crate::error::{Result, SecurityError}; use crate::security::master_key::MasterKey; @@ -23,14 +20,12 @@ fn tpm_dev_exists() -> bool { pub struct LinuxTpmStore { storage_path: PathBuf, - #[cfg(feature = "tpm")] tcti: TctiNameConf, } -#[cfg(all(target_os = "linux", feature = "tpm"))] mod proxy { pub use crate::security::tpm::protocol::{ - receive_message, send_message, TpmRequest, TpmResponse, + TpmRequest, TpmResponse, receive_message, send_message, }; use std::os::unix::net::UnixStream; use std::path::PathBuf; @@ -69,7 +64,6 @@ impl LinuxTpmStore { } pub fn with_storage_path(storage_path: PathBuf) -> Result { - #[cfg(feature = "tpm")] { let tcti = if std::path::Path::new("/dev/tpmrm0").exists() { TctiNameConf::from_str("device:/dev/tpmrm0").map_err(common::tpm_err)? @@ -81,11 +75,6 @@ impl LinuxTpmStore { Ok(Self { storage_path, tcti }) } - - #[cfg(not(feature = "tpm"))] - { - Ok(Self { storage_path }) - } } fn get_sealed_path(&self) -> PathBuf { @@ -94,12 +83,10 @@ impl LinuxTpmStore { // ── Context & availability ───────────────────────────────────── - #[cfg(feature = "tpm")] pub fn create_context(&self) -> Result { Context::new(self.tcti.clone()).map_err(common::tpm_err) } - #[cfg(feature = "tpm")] pub fn check_context_silent(&self) -> bool { self.check_direct_access() || { #[cfg(target_os = "linux")] @@ -114,7 +101,6 @@ impl LinuxTpmStore { } /// Verifies if the current process has direct access to the TPM device by attempting a real (but lightweight) operation. - #[cfg(feature = "tpm")] pub fn check_direct_access(&self) -> bool { if !tpm_dev_exists() { return false; @@ -127,7 +113,6 @@ impl LinuxTpmStore { } /// Returns true if TPM is present but direct access fails AND proxy is not running. - #[cfg(feature = "tpm")] pub fn check_needs_elevation(&self) -> bool { if self.check_direct_access() { return false; @@ -142,16 +127,13 @@ impl LinuxTpmStore { } /// Verifies if the proxy helper is running and responsive. - #[cfg(all(target_os = "linux", feature = "tpm"))] pub fn verify_proxy(&self) -> bool { proxy::call_proxy(proxy::TpmRequest::Ping).is_ok() } } // ── SecretStore impl ─────────────────────────────────────────────── - impl SecretStore for LinuxTpmStore { - #[cfg(feature = "tpm")] fn store(&self, key: &MasterKey) -> Result<()> { #[cfg(target_os = "linux")] { @@ -202,12 +184,6 @@ impl SecretStore for LinuxTpmStore { } } - #[cfg(not(feature = "tpm"))] - fn store(&self, _key: &MasterKey) -> Result<()> { - Err(SecurityError::Tpm("TPM support not compiled in".into())) - } - - #[cfg(feature = "tpm")] fn retrieve(&self) -> Result { match self.create_context() { Ok(mut ctx) => { @@ -253,11 +229,6 @@ impl SecretStore for LinuxTpmStore { } } - #[cfg(not(feature = "tpm"))] - fn retrieve(&self) -> Result { - Err(SecurityError::Tpm("TPM support not compiled in".into())) - } - fn delete(&self) -> Result<()> { let path = self.get_sealed_path(); @@ -279,15 +250,7 @@ impl SecretStore for LinuxTpmStore { } fn is_available(&self) -> bool { - #[cfg(feature = "tpm")] - { - tpm_dev_exists() - } - - #[cfg(not(feature = "tpm"))] - { - false - } + { tpm_dev_exists() } } fn name(&self) -> &'static str { diff --git a/src-tauri/src/security/tpm/store/mod.rs b/src-tauri/src/security/tpm/store/mod.rs index 286b0444..60b2333b 100644 --- a/src-tauri/src/security/tpm/store/mod.rs +++ b/src-tauri/src/security/tpm/store/mod.rs @@ -1,4 +1,4 @@ -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", feature = "tpm"))] pub mod linux; #[cfg(all(target_os = "windows", feature = "tpm"))] @@ -35,11 +35,11 @@ pub struct NoTpmStore; impl SecretStore for NoTpmStore { fn store(&self, _key: &MasterKey) -> Result<()> { - Err(SecurityError::NoSecureStorageAvailable) + Err(SecurityError::Tpm("TPM support not compiled in".into())) } fn retrieve(&self) -> Result { - Err(SecurityError::NoSecureStorageAvailable) + Err(SecurityError::Tpm("TPM support not compiled in".into())) } fn delete(&self) -> Result<()> { diff --git a/src-tauri/src/security/tpm/store/windows.rs b/src-tauri/src/security/tpm/store/windows.rs index 6389d49d..2787f97f 100644 --- a/src-tauri/src/security/tpm/store/windows.rs +++ b/src-tauri/src/security/tpm/store/windows.rs @@ -1,11 +1,8 @@ -#[cfg(feature = "tpm")] -use tss_esapi::{tcti_ldr::TctiNameConf, Context}; - -#[cfg(feature = "tpm")] -use std::str::FromStr; +use tss_esapi::{Context, tcti_ldr::TctiNameConf}; use std::fs; use std::path::PathBuf; +use std::str::FromStr; use crate::error::{Result, SecurityError}; use crate::security::master_key::MasterKey; @@ -17,7 +14,6 @@ use super::common; pub struct WindowsTpmStore { storage_path: PathBuf, - #[cfg(feature = "tpm")] tcti: TctiNameConf, } @@ -27,32 +23,23 @@ impl WindowsTpmStore { } pub fn with_storage_path(storage_path: PathBuf) -> Result { - #[cfg(feature = "tpm")] { let tcti = TctiNameConf::from_str("tbs").map_err(common::tpm_err)?; Ok(Self { storage_path, tcti }) } - - #[cfg(not(feature = "tpm"))] - { - Ok(Self { storage_path }) - } } fn get_sealed_path(&self) -> PathBuf { self.storage_path.join(common::SEALED_FILE_NAME) } - #[cfg(feature = "tpm")] fn create_context(&self) -> Result { Context::new(self.tcti.clone()).map_err(common::tpm_err) } } // ── SecretStore impl ─────────────────────────────────────────────── - impl SecretStore for WindowsTpmStore { - #[cfg(feature = "tpm")] fn store(&self, key: &MasterKey) -> Result<()> { let mut ctx = self.create_context()?; let primary = common::create_primary_key(&mut ctx)?; @@ -68,12 +55,6 @@ impl SecretStore for WindowsTpmStore { Ok(()) } - #[cfg(not(feature = "tpm"))] - fn store(&self, _key: &MasterKey) -> Result<()> { - Err(SecurityError::Tpm("TPM support not compiled in".into())) - } - - #[cfg(feature = "tpm")] fn retrieve(&self) -> Result { let sealed = fs::read(self.get_sealed_path()).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { @@ -93,11 +74,6 @@ impl SecretStore for WindowsTpmStore { MasterKey::from_bytes(&unsealed) } - #[cfg(not(feature = "tpm"))] - fn retrieve(&self) -> Result { - Err(SecurityError::Tpm("TPM support not compiled in".into())) - } - fn delete(&self) -> Result<()> { let path = self.get_sealed_path(); if path.exists() { @@ -111,17 +87,11 @@ impl SecretStore for WindowsTpmStore { } fn is_available(&self) -> bool { - #[cfg(feature = "tpm")] { self.create_context() .map(|mut ctx| ctx.get_random(8).is_ok()) .unwrap_or(false) } - - #[cfg(not(feature = "tpm"))] - { - false - } } fn name(&self) -> &'static str { diff --git a/src-tauri/src/webview_policy.rs b/src-tauri/src/webview_policy.rs deleted file mode 100644 index 792636bf..00000000 --- a/src-tauri/src/webview_policy.rs +++ /dev/null @@ -1,39 +0,0 @@ -use tauri::{Runtime, WebviewWindow}; -use tracing::{info, warn}; - -pub fn install_network_block(window: &WebviewWindow, proxy_port: u16) { - #[cfg(target_os = "linux")] - install_linux(window, proxy_port); - - #[cfg(not(target_os = "linux"))] - { - let _ = window; - let _ = proxy_port; - } -} - -#[cfg(target_os = "linux")] -fn install_linux(window: &WebviewWindow, proxy_port: u16) { - use webkit2gtk::{NetworkProxyMode, NetworkProxySettings, WebViewExt, WebsiteDataManagerExt}; - - let result = window.with_webview(move |webview| { - let wk = webview.inner(); - - let proxy_uri = format!("http://127.0.0.1:{proxy_port}"); - let mut proxy = NetworkProxySettings::new( - Some(proxy_uri.as_str()), - &["localhost", "127.0.0.1", "::1", "*.localhost"], - ); - - if let Some(manager) = wk.website_data_manager() { - manager.set_network_proxy_settings(NetworkProxyMode::Custom, Some(&mut proxy)); - info!("webview network block: null proxy installed on port {proxy_port}"); - } else { - warn!("webview network block: could not get WebsiteDataManager"); - } - }); - - if let Err(e) = result { - warn!("webview network block: failed to install: {e}"); - } -} diff --git a/src-tauri/tests/db.rs b/src-tauri/tests/db.rs index 33522560..b79262fa 100644 --- a/src-tauri/tests/db.rs +++ b/src-tauri/tests/db.rs @@ -1,4 +1,5 @@ use chrono::Utc; +use postail_project_lib::security::SecurityManager; use rusqlite::{Connection, params}; use std::fs; use tempfile::NamedTempFile; @@ -9,7 +10,6 @@ use postail_project_lib::db::{ AccountInput, Credentials, ImapConfig, MessageUpsertData, OAuthCredentials, PasswordCredentials, SmtpConfig, add_account, list_accounts, remove_account, }; -use postail_project_lib::security::Crypto; fn init_temp_db() -> Connection { let temp_file = NamedTempFile::new().unwrap(); diff --git a/src/components/Inbox/MessageView/EmailFreezeNotice.tsx b/src/components/Inbox/MessageView/EmailFreezeNotice.tsx new file mode 100644 index 00000000..beb80133 --- /dev/null +++ b/src/components/Inbox/MessageView/EmailFreezeNotice.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { invoke } from '@tauri-apps/api/core' +import { AlertTriangle, X } from 'lucide-react' +import { Button } from '@/components/ui/button' + +export interface FreezeStats { + memory_bytes: number +} + +interface EmailFreezeNoticeProps { + stats: FreezeStats + onDismiss: () => void +} + +export const EmailFreezeNotice: React.FC = ({ stats, onDismiss }) => { + const { t } = useTranslation('inbox') + const memoryMB = (stats.memory_bytes / 1024 / 1024).toFixed(1) + + const handleResume = async () => { + try { + await invoke('resume_email_webview') + } catch (e) { + console.error('Failed to resume webview', e) + } + } + + return ( +
+
+
+ +
+
+ + {t('messageView.freezeNotice.title')} + + + {t('messageView.freezeNotice.description')} •{' '} + {t('messageView.freezeNotice.memoryStats', { memory: memoryMB })} + +
+
+ +
+ + + +
+
+ ) +} diff --git a/src/components/Inbox/MessageView/MessageViewBody.tsx b/src/components/Inbox/MessageView/MessageViewBody.tsx index 2d60e2d6..3d69605d 100644 --- a/src/components/Inbox/MessageView/MessageViewBody.tsx +++ b/src/components/Inbox/MessageView/MessageViewBody.tsx @@ -1,218 +1,153 @@ -import { useEffect, useRef, useState, useMemo } from 'react' +import { useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' import { useThemeStore } from '@/stores/themeStore' import { useTypedTranslation } from '@/hooks/useTypedTranslation' -import { ConfirmationDialog } from '@/components/ui/custom/ConfirmationDialog' -import { openUrl } from '@tauri-apps/plugin-opener' +import { EmailFreezeNotice, FreezeStats } from './EmailFreezeNotice' import type { MessageViewBodyProps } from '@/types/components/shared' +interface PrepareEmailViewResult { + hasExternalResources: boolean + isPlainOnly: boolean +} + export const MessageViewBody = ({ - htmlContent, - plainContent, + accountId, + mailbox, + uid, viewMode, allowExternalResources = false, - inline_images = [], onExternalDetected, onLoadingChange, }: MessageViewBodyProps) => { const { t } = useTypedTranslation(['security', 'common']) const accentColor = useThemeStore((s) => s.accentColor) - const iframeRef = useRef(null) - const isReadyRef = useRef(false) - - const [iframeSrcDoc, setIframeSrcDoc] = useState(null) - - const [pendingUrl, setPendingUrl] = useState(null) - const [warningOpen, setWarningOpen] = useState(false) + const containerRef = useRef(null) + const rafPendingRef = useRef(false) - const effectiveMode = !htmlContent || !htmlContent.trim() ? 'plain' : viewMode + const [frozenStats, setFrozenStats] = useState(null) - const inlineImagesHash = JSON.stringify(inline_images) - - // Memoize mapped images to prevent unnecessary re-renders - const mappedImages = useMemo(() => { - return inline_images - .filter((img) => img.cid && img.cached_path) - .map((img) => ({ - cid: img.cid!, - cachedPath: img.cached_path!, - mimeType: img.mime_type, - })) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inlineImagesHash]) - - // 1. Process HTML Content + // 1. Prepare content in Rust - builds the full HTML, processes inline images, rewrites external resources, and stores it for the protocol handler. useEffect(() => { - if (effectiveMode !== 'html') return - - isReadyRef.current = false onLoadingChange?.(true) - const hasDarkMode = - htmlContent.includes('prefers-color-scheme: dark') || - htmlContent.includes('data-ogsc') || - htmlContent.includes('data-ogsb') - - const iframeBg = hasDarkMode ? 'transparent' : '#ffffff' - const iframeTextColor = hasDarkMode ? 'inherit' : '#1a1a1a' - const colorScheme = hasDarkMode ? 'dark light' : 'light' - - // Minimalist HTML wrapper relying on ResizeObserver - const htmlTemplate = ` - - - - - - - -
${htmlContent}
- - -` - - invoke<{ hasExternalResources: boolean; processedHtml: string }>('set_email_view_content', { - html: htmlTemplate, - inlineImages: mappedImages, + invoke('prepare_email_view', { + accountId, + mailbox, + uid, + accentColor, allowExternal: allowExternalResources, + viewMode, }) .then((res) => { if (res.hasExternalResources) onExternalDetected?.() - setIframeSrcDoc(res.processedHtml) + // Tell the native webview to reload so it fetches the freshly prepared HTML + invoke('reload_email_webview').catch(console.error) }) .catch(console.error) .finally(() => onLoadingChange?.(false)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [htmlContent, effectiveMode, accentColor, allowExternalResources, mappedImages]) + }, [accountId, mailbox, uid, viewMode, accentColor, allowExternalResources]) - // 2. Handle Iframe Messages + // 2. Spawn / destroy the child webview per message useEffect(() => { - const handler = (e: MessageEvent) => { - if (e.source !== iframeRef.current?.contentWindow) return - - if (e.data?.type === 'resize' && typeof e.data.height === 'number') { - // Bypass React state for height to prevent laggy re-renders - if (iframeRef.current) { - iframeRef.current.style.height = `${e.data.height}px` - } - } - - if (e.data?.type === 'link' && e.data.url) { - setPendingUrl(e.data.url) - setWarningOpen(true) - } + const win = getCurrentWindow() + invoke('create_email_webview', { window: win }).catch(console.error) + + return () => { + invoke('destroy_email_webview').catch(console.error) + } + }, [accountId, mailbox, uid]) + + // 3. ResizeObserver & Scroll — sync native webview bounds to the placeholder div + useEffect(() => { + const el = containerRef.current + if (!el) return + + const syncBounds = () => { + rafPendingRef.current = false + const rect = el.getBoundingClientRect() + const dpr = window.devicePixelRatio ?? 1 + + invoke('update_email_webview_bounds', { + x: rect.x / dpr, + y: rect.y / dpr, + width: rect.width / dpr, + height: rect.height / dpr, + }).catch(console.error) } - window.addEventListener('message', handler) - return () => window.removeEventListener('message', handler) + const ro = new ResizeObserver(() => { + if (rafPendingRef.current) return + rafPendingRef.current = true + requestAnimationFrame(syncBounds) + }) + + ro.observe(el) + + const scrollParent = el.closest('.overflow-y-auto') || window + const onScroll = () => { + if (rafPendingRef.current) return + rafPendingRef.current = true + requestAnimationFrame(syncBounds) + } + scrollParent.addEventListener('scroll', onScroll, { passive: true }) + + requestAnimationFrame(syncBounds) + + return () => { + ro.disconnect() + scrollParent.removeEventListener('scroll', onScroll) + } + }, [frozenStats]) + + // 5. Native OS dialog for external links + useEffect(() => { + const unlisten = listen<{ url: string }>('email_link_clicked', (event) => { + invoke('confirm_external_link', { + url: event.payload.url, + title: t('security:externalLink.title'), + body: `${t('security:externalLink.description')}\n\n${event.payload.url}`, + okLabel: t('security:externalLink.open'), + cancelLabel: t('security:externalLink.cancel'), + }).catch(console.error) + }) + + return () => { + unlisten.then((fn) => fn()) + } + }, [t]) + useEffect(() => { + const unlistenFrozen = listen('email_webview_frozen', (event) => { + setFrozenStats(event.payload) + }) + + const unlistenResumed = listen('email_webview_resumed', () => { + setFrozenStats(null) + }) + + return () => { + unlistenFrozen.then((fn) => fn()) + unlistenResumed.then((fn) => fn()) + } }, []) - // 3. Render Plain Text - if (effectiveMode === 'plain') { - return ( -
-
-					{plainContent || '(No content)'}
-				
-
- ) - } - - // 4. Render HTML return ( - <> -
-
- {iframeSrcDoc && ( -