//! File attachment commands. //! //! Provides commands for attaching files to tasks and projects, //! backed by a content-addressed local blob store and synced via SyncKit. use chrono::{DateTime, Utc}; use serde::Serialize; use sha2::{Sha256, Digest}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ AttachmentId, AttachmentMeta, EmailId, NewAttachment, ProjectId, TaskId, format_file_size, mime_from_extension, }; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound, ResultApiError}; /// Maximum file size for attachments (50 MB). const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; /// Get the size (in bytes) of a file on disk. Used by the compose UI to surface /// a per-message attachment total and warn before SMTP rejects an oversized send. #[tauri::command] #[instrument(skip_all)] pub async fn get_file_size(file_path: String) -> Result { let p = Path::new(&file_path); if file_path.contains("..") { return Err(ApiError::validation("filePath", "Path traversal not allowed")); } if !p.is_file() { return Err(ApiError::validation("filePath", "File does not exist or is not a regular file")); } let metadata = std::fs::metadata(p) .map_api_err("Failed to read file metadata", ApiError::internal)?; Ok(metadata.len()) } // ============ Types ============ /// Attachment response with pre-computed display fields. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentResponse { pub id: AttachmentId, pub task_id: Option, pub project_id: Option, pub filename: String, pub file_size: i64, pub mime_type: String, pub blob_hash: String, pub source_email_id: Option, pub created_at: DateTime, pub file_size_formatted: String, pub has_local_blob: bool, } // ============ Commands ============ /// Attach a file to a task or project. /// /// Reads the file, computes SHA-256, copies to blob store (dedup), and creates /// the attachment record. The `file_path` comes from the JS file picker dialog. #[tauri::command] #[instrument(skip_all)] pub async fn add_attachment( state: State<'_, Arc>, task_id: Option, project_id: Option, file_path: String, ) -> Result { // Validate at least one parent if task_id.is_none() && project_id.is_none() { return Err(ApiError::validation_msg("Either taskId or projectId is required")); } let source_path = Path::new(&file_path); // Validate path exists and is a file if !source_path.is_file() { return Err(ApiError::validation("filePath", "File does not exist or is not a regular file")); } // Validate no path traversal if file_path.contains("..") { return Err(ApiError::validation("filePath", "Path traversal not allowed")); } // Read file metadata let metadata = std::fs::metadata(source_path) .map_api_err("Failed to read file metadata", ApiError::internal)?; if metadata.len() > MAX_FILE_SIZE { return Err(ApiError::validation("filePath", format!( "File too large ({}, max {})", format_file_size(metadata.len() as i64), format_file_size(MAX_FILE_SIZE as i64), ))); } // Read file and compute SHA-256 let file_data = std::fs::read(source_path) .map_api_err("Failed to read file", ApiError::internal)?; let hash = { let mut hasher = Sha256::new(); hasher.update(&file_data); format!("{:x}", hasher.finalize()) }; // Ensure blobs directory exists let blobs_dir = state.data_dir.join("blobs"); std::fs::create_dir_all(&blobs_dir) .map_api_err("Failed to create blobs directory", ApiError::internal)?; // Copy to blob store (skip if hash already exists — dedup) let blob_path = blobs_dir.join(&hash); if !blob_path.exists() { std::fs::write(&blob_path, &file_data) .map_api_err("Failed to write blob", ApiError::internal)?; } // Extract filename from path let filename = source_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unnamed") .to_string(); let mime_type = mime_from_extension(&filename).to_string(); let file_size = file_data.len() as i64; let attachment = state.attachments .create(DESKTOP_USER_ID, NewAttachment { task_id, project_id, filename, file_size, mime_type, blob_hash: hash, source_email_id: None, }) .await?; Ok(to_response(attachment, &state.data_dir)) } /// List attachments for a task or project. #[tauri::command] #[instrument(skip_all)] pub async fn list_attachments( state: State<'_, Arc>, task_id: Option, project_id: Option, ) -> Result, ApiError> { let attachments = if let Some(tid) = task_id { state.attachments.list_for_task(tid, DESKTOP_USER_ID).await? } else if let Some(pid) = project_id { state.attachments.list_for_project(pid, DESKTOP_USER_ID).await? } else { return Err(ApiError::validation_msg("Either taskId or projectId is required")); }; let data_dir = &state.data_dir; Ok(attachments.into_iter().map(|a| to_response(a, data_dir)).collect()) } /// Delete an attachment record. /// /// Does NOT delete the blob from disk — other attachments may reference the same hash. #[tauri::command] #[instrument(skip_all)] pub async fn delete_attachment( state: State<'_, Arc>, id: AttachmentId, ) -> Result { Ok(state.attachments.delete(id, DESKTOP_USER_ID).await?) } /// Open an attachment with the system default application. #[tauri::command] #[instrument(skip_all)] pub async fn open_attachment( state: State<'_, Arc>, id: AttachmentId, ) -> Result<(), ApiError> { let attachment = state.attachments .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("attachment", id)?; let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Blob not available locally — sync required")); } // Create a temp directory keyed by blob hash so different attachments with the same // filename don't overwrite each other. let hash_prefix = if attachment.blob_hash.len() >= 8 { &attachment.blob_hash[..8] } else { &attachment.blob_hash }; let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix); std::fs::create_dir_all(&temp_dir) .map_api_err("Failed to create temp dir", ApiError::internal)?; // Sanitize filename: strip path separators, .., and control characters to prevent path traversal let safe_name: String = attachment.filename .replace(['/', '\\'], "_") .replace("..", "_") .chars() .filter(|c| !c.is_control()) .collect(); let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; let temp_path = temp_dir.join(&safe_name); // Copy blob to temp with original filename (overwrite if exists) std::fs::copy(&blob_path, &temp_path) .map_api_err("Failed to prepare file for opening", ApiError::internal)?; open::that(&temp_path) .map_api_err("Failed to open file", ApiError::internal)?; Ok(()) } /// Save an attachment to a user-chosen destination. #[tauri::command] #[instrument(skip_all)] pub async fn save_attachment( state: State<'_, Arc>, id: AttachmentId, destination: String, ) -> Result<(), ApiError> { let attachment = state.attachments .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("attachment", id)?; let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Blob not available locally — sync required")); } std::fs::copy(&blob_path, &destination) .map_api_err("Failed to save file", ApiError::internal)?; Ok(()) } /// Convert email attachments to task attachments. /// /// Reads the email's pre-stored `attachment_meta` JSON (populated during IMAP sync), /// verifies each blob exists on disk, and creates `Attachment` records linked to the task. #[tauri::command] #[instrument(skip_all)] pub async fn convert_email_attachments( state: State<'_, Arc>, email_id: EmailId, task_id: TaskId, ) -> Result, ApiError> { // Verify email and task exist let email = state.emails .get_by_id(email_id, DESKTOP_USER_ID) .await? .or_not_found("email", email_id)?; let _task = state.tasks .get_by_id(task_id, DESKTOP_USER_ID) .await? .or_not_found("task", task_id)?; // Parse attachment metadata — if none, return empty let meta_json = match email.attachment_meta { Some(ref json) if !json.is_empty() => json, _ => return Ok(Vec::new()), }; let metas: Vec = serde_json::from_str(meta_json) .map_api_err("Invalid attachment_meta JSON", ApiError::internal)?; let mut results = Vec::new(); let blobs_dir = state.data_dir.join("blobs"); for meta in metas { // Verify blob exists on disk let blob_path = blobs_dir.join(&meta.blob_hash); if !blob_path.exists() { tracing::warn!( blob_hash = %meta.blob_hash, filename = %meta.filename, "Attachment blob missing — skipping" ); continue; } // Dedup: skip if attachment with same source_email_id + blob_hash already exists let existing = state.attachments .list_by_blob_hash(&meta.blob_hash, DESKTOP_USER_ID) .await?; if existing.iter().any(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) { // Already converted — include in results without creating duplicate if let Some(a) = existing.into_iter().find(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) { results.push(to_response(a, &state.data_dir)); } continue; } let attachment = state.attachments .create(DESKTOP_USER_ID, NewAttachment { task_id: Some(task_id), project_id: None, filename: meta.filename, file_size: meta.size as i64, mime_type: meta.mime_type, blob_hash: meta.blob_hash, source_email_id: Some(email_id), }) .await?; results.push(to_response(attachment, &state.data_dir)); } Ok(results) } /// Open an email attachment blob with the system default application. /// /// Unlike `open_attachment`, this works directly on the blob store using the hash /// and filename from the email's `attachment_meta` — no Attachment record needed. #[tauri::command] #[instrument(skip_all)] pub async fn open_email_blob( state: State<'_, Arc>, blob_hash: String, filename: String, ) -> Result<(), ApiError> { let blob_path = state.data_dir.join("blobs").join(&blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Attachment not available locally — sync required")); } let hash_prefix = if blob_hash.len() >= 8 { &blob_hash[..8] } else { &blob_hash }; let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix); std::fs::create_dir_all(&temp_dir) .map_api_err("Failed to create temp dir", ApiError::internal)?; let safe_name: String = filename .replace(['/', '\\'], "_") .replace("..", "_") .chars() .filter(|c| !c.is_control()) .collect(); let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; let temp_path = temp_dir.join(&safe_name); std::fs::copy(&blob_path, &temp_path) .map_api_err("Failed to prepare file for opening", ApiError::internal)?; open::that(&temp_path) .map_api_err("Failed to open file", ApiError::internal)?; Ok(()) } /// Save an email attachment blob to a user-chosen destination. #[tauri::command] #[instrument(skip_all)] pub async fn save_email_blob( state: State<'_, Arc>, blob_hash: String, destination: String, ) -> Result<(), ApiError> { let blob_path = state.data_dir.join("blobs").join(&blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Attachment not available locally — sync required")); } std::fs::copy(&blob_path, &destination) .map_api_err("Failed to save file", ApiError::internal)?; Ok(()) } // ============ Helpers ============ fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse { let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists(); AttachmentResponse { id: a.id, task_id: a.task_id, project_id: a.project_id, filename: a.filename, file_size_formatted: format_file_size(a.file_size), file_size: a.file_size, mime_type: a.mime_type, blob_hash: a.blob_hash, source_email_id: a.source_email_id, created_at: a.created_at, has_local_blob, } } /// Get the path to a blob file by hash. pub(crate) fn blob_path(data_dir: &Path, hash: &str) -> PathBuf { data_dir.join("blobs").join(hash) }