//! Email management commands. //! //! Provides CRUD operations and status management for emails. //! Email account management is in `email_account.rs`; //! sync operations are in `email_sync.rs`. use chrono::{Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::{instrument, warn}; use goingson_core::{AttachmentMeta, Email, EmailAccountId, EmailId, NewEmail, NewEmailWithTracking, ProjectId}; use crate::email::{uses_jmap, ImapClient, SmtpClient}; use crate::state::{AppState, DESKTOP_USER_ID}; use goingson_db_sqlite::utils::is_valid_email; use goingson_core::date_utils::{format_relative_future, format_elapsed_time}; use super::{ApiError, LinkProjectInput, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput}; use super::email_account::{uses_oauth_imap, get_account_password, get_valid_access_token}; // ============ IMAP Client Helper ============ /// Result of attempting to build an IMAP client for an email's account. /// /// If the email has no associated account/UID, or the account uses JMAP, /// returns `None`. If credential retrieval fails, logs a warning and /// returns `None` so the caller can fall through to the local-only path. async fn build_imap_client( state: &Arc, account_id: EmailAccountId, operation: &str, ) -> Option<(ImapClient, String)> { let account = match state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await { Ok(Some(a)) => a, _ => return None, }; if uses_jmap(&account) { return None; } let archive_folder = account .archive_folder_name .as_deref() .unwrap_or("Archive") .to_string(); let client = if uses_oauth_imap(&account) { match get_valid_access_token(state, &account).await { Ok(token) => ImapClient::with_oauth( &account.imap_server, account.imap_port as u16, &account.email_address, &token, ), Err(e) => { warn!("Failed to get OAuth token for {}: {}", operation, e); return None; } } } else { match get_account_password(&account) { Ok(password) => ImapClient::with_password(&account, &password), Err(e) => { warn!("Failed to get password for {}: {}", operation, e); return None; } } }; Some((client, archive_folder)) } // ============ Types ============ #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailInput { pub project_id: Option, pub from_address: String, pub to_address: String, pub subject: String, pub body: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendEmailInput { pub account_id: EmailAccountId, pub to_address: String, pub cc_address: Option, pub bcc_address: Option, pub subject: String, pub body: String, pub project_id: Option, /// Message-ID of the email being replied to (sets In-Reply-To header). pub in_reply_to: Option, /// Full References header chain for threading. pub references: Option, /// Thread ID to join (from the original email's thread). pub thread_id: Option, /// File paths to attach (from file picker dialog). #[serde(default)] pub attachment_paths: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DraftInput { /// If updating an existing draft, pass its ID. pub id: Option, pub account_id: Option, pub to_address: Option, pub cc_address: Option, pub bcc_address: Option, pub subject: Option, pub body: Option, pub in_reply_to: Option, pub references: Option, pub thread_id: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct UnreadCountResponse { pub count: i64, } /// Email response with pre-computed fields for UI. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmailResponse { pub id: EmailId, pub project_id: Option, pub project_name: Option, pub from: String, pub to: String, pub subject: String, pub body: String, pub html_body: Option, pub is_read: bool, pub is_archived: bool, pub received_at: chrono::DateTime, pub message_id: Option, pub in_reply_to: Option, pub thread_id: Option, pub email_account_id: Option, pub is_outgoing: bool, pub snoozed_until: Option>, pub waiting_for_response: bool, pub waiting_since: Option>, pub expected_response_date: Option>, // Pre-computed fields pub is_snoozed: bool, pub is_waiting: bool, pub is_response_overdue: bool, pub received_formatted: String, /// Human-readable snooze time: "today", "tomorrow", "+3d", "Mar 15" pub snoozed_until_formatted: Option, /// Parsed attachment metadata from IMAP sync (filename, size, mime_type, blob_hash). pub attachments: Vec, /// IMAP source folder (INBOX, Sent, Archive, etc.). pub source_folder: Option, /// Local labels/tags. pub labels: Vec, /// Whether this email is a draft. pub is_draft: bool, /// CC recipients (drafts). pub cc_address: Option, /// BCC recipients (drafts). pub bcc_address: Option, /// Account ID to send from (drafts). pub draft_account_id: Option, } /// Attachment info exposed to the frontend for display. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmailAttachmentInfo { pub filename: String, pub mime_type: String, pub size: usize, pub blob_hash: String, pub size_formatted: String, } impl From for EmailResponse { fn from(e: Email) -> Self { let is_snoozed = e.is_snoozed(); let is_waiting = e.is_waiting(); let is_response_overdue = e.is_response_overdue(); let now = Utc::now(); let snoozed_until_formatted = e.snoozed_until.map(|s| format_relative_future(s, now)); let received_formatted = format_elapsed_time(e.received_at, now); let attachments = e.attachment_meta.as_deref() .and_then(|json| serde_json::from_str::>(json).ok()) .unwrap_or_default() .into_iter() .map(|m| EmailAttachmentInfo { size_formatted: goingson_core::format_file_size(m.size as i64), filename: m.filename, mime_type: m.mime_type, size: m.size, blob_hash: m.blob_hash, }) .collect(); EmailResponse { id: e.id, project_id: e.project_id, project_name: e.project_name, from: e.from, to: e.to, subject: e.subject, body: e.body, html_body: e.html_body, is_read: e.is_read, is_archived: e.is_archived, received_at: e.received_at, message_id: e.message_id, in_reply_to: e.in_reply_to, thread_id: e.thread_id, email_account_id: e.email_account_id, is_outgoing: e.is_outgoing, snoozed_until: e.snoozed_until, waiting_for_response: e.waiting_for_response, waiting_since: e.waiting_since, expected_response_date: e.expected_response_date, is_snoozed, is_waiting, is_response_overdue, received_formatted, snoozed_until_formatted, attachments, source_folder: e.source_folder, labels: e.labels, is_draft: e.is_draft, cc_address: e.cc_address, bcc_address: e.bcc_address, draft_account_id: e.draft_account_id, } } } /// A thread of emails, pre-grouped by the backend. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmailThreadResponse { pub thread_id: String, pub most_recent_email: EmailResponse, pub thread_count: usize, pub has_unread: bool, } /// Pagination parameters for email listing. #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailPaginationInput { #[serde(default)] pub include_archived: bool, pub offset: Option, pub limit: Option, /// Filter by source folder (e.g. "INBOX", "Sent", "Archive"). pub folder: Option, /// Filter by label. pub label: Option, } /// Paginated response with total count for UI pagination. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PaginatedEmailThreadsResponse { pub threads: Vec, pub total: i64, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SendEmailResponse { pub success: bool, pub message_id: Option, pub saved_email: EmailResponse, pub new_implicit_contacts: Vec, } // ============ Email Commands ============ /// Lists all emails for the current user. #[tauri::command] #[instrument(skip_all)] pub async fn list_emails(state: State<'_, Arc>, include_archived: Option) -> Result, ApiError> { let emails = state.emails.list_all(DESKTOP_USER_ID, include_archived.unwrap_or(false)).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) } /// Lists emails grouped by thread with pagination. /// /// The repository groups emails by `thread_id` (set during sync — see /// `process_fetched_emails`), returns the most recent email per thread, /// and includes unread status and thread depth for the UI. #[tauri::command] #[instrument(skip_all)] pub async fn list_emails_threaded( state: State<'_, Arc>, params: Option, ) -> Result { let params = params.unwrap_or_default(); let (threads, total) = state.emails .list_threaded(DESKTOP_USER_ID, params.include_archived, params.offset, params.limit, params.folder.as_deref(), params.label.as_deref()) .await?; Ok(PaginatedEmailThreadsResponse { threads: threads.into_iter().map(|t| EmailThreadResponse { thread_id: t.thread_id, most_recent_email: EmailResponse::from(t.most_recent_email), thread_count: t.thread_count, has_unread: t.has_unread, }).collect(), total, }) } /// Retrieves a single email by ID. #[tauri::command] #[instrument(skip_all)] pub async fn get_email(state: State<'_, Arc>, id: EmailId) -> Result, ApiError> { let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await?; Ok(email.map(EmailResponse::from)) } /// Opens an email in the system's default web browser. #[tauri::command] #[instrument(skip_all)] pub async fn open_email_in_browser(state: State<'_, Arc>, id: EmailId) -> Result<(), ApiError> { let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await? .or_not_found("email", id)?; let html_content = if let Some(ref html) = email.html_body { let sanitized_body = docengine::sanitize_html(html); format!( r#" {} "#, html_escape(&email.subject), html_escape(&email.from), html_escape(&email.to), html_escape(&email.subject), email.received_at.format("%Y-%m-%d %H:%M:%S UTC"), sanitized_body ) } else { let body_html = html_escape(&email.body).replace('\n', "
\n"); format!( r#" {} "#, html_escape(&email.subject), html_escape(&email.from), html_escape(&email.to), html_escape(&email.subject), email.received_at.format("%Y-%m-%d %H:%M:%S UTC"), body_html ) }; let temp_dir = std::env::temp_dir(); let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple()); let file_path = temp_dir.join(file_name); tokio::fs::write(&file_path, html_content).await .map_api_err("Failed to write temp file", ApiError::internal)?; let path = file_path.clone(); tokio::task::spawn_blocking(move || open::that(&path)).await .map_api_err("Task join error", ApiError::internal)? .map_api_err("Failed to open browser", ApiError::internal)?; // Clean up temp file after a delay to give the browser time to load it tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(30)).await; let _ = tokio::fs::remove_file(&file_path).await; }); Ok(()) } /// Remove stale `goingson_email_*.html` temp files from previous sessions. pub async fn cleanup_stale_temp_files() { let temp_dir = std::env::temp_dir(); let mut entries = match tokio::fs::read_dir(&temp_dir).await { Ok(e) => e, Err(_) => return, }; while let Ok(Some(entry)) = entries.next_entry().await { if let Some(name) = entry.file_name().to_str() { if name.starts_with("goingson_email_") && name.ends_with(".html") { let _ = tokio::fs::remove_file(entry.path()).await; } } } } /// Escape HTML special characters to prevent XSS when injecting user /// content (email bodies, subjects) into the browser preview template. fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } /// Creates a new email record (draft or imported). #[tauri::command] #[instrument(skip_all)] pub async fn create_email(state: State<'_, Arc>, input: EmailInput) -> Result { if input.subject.trim().is_empty() { return Err(ApiError::validation("subject", "Subject is required")); } if !is_valid_email(&input.from_address) { return Err(ApiError::validation("fromAddress", "Invalid 'from' email address")); } if !is_valid_email(&input.to_address) { return Err(ApiError::validation("toAddress", "Invalid 'to' email address")); } let new_email = NewEmail { project_id: input.project_id, from_address: input.from_address, to_address: input.to_address, subject: input.subject, body: input.body, is_read: false, received_at: None, }; let email = state.emails.create(DESKTOP_USER_ID, new_email).await?; Ok(EmailResponse::from(email)) } /// Save an email draft (create or update). #[tauri::command] #[instrument(skip_all)] pub async fn save_email_draft(state: State<'_, Arc>, input: DraftInput) -> Result { let id = input.id.unwrap_or_else(EmailId::new); let account_id = input.account_id; let from = if let Some(aid) = account_id { let acct = state.email_accounts.get_by_id(aid, DESKTOP_USER_ID).await?; acct.map(|a| a.email_address).unwrap_or_default() } else { String::new() }; let email = state.emails.save_draft( id, DESKTOP_USER_ID, &from, &input.to_address.unwrap_or_default(), input.cc_address.as_deref(), input.bcc_address.as_deref(), &input.subject.unwrap_or_default(), &input.body.unwrap_or_default(), account_id, input.in_reply_to.as_deref(), input.references.as_deref(), input.thread_id.as_deref(), ).await?; Ok(EmailResponse::from(email)) } /// List all draft emails. #[tauri::command] #[instrument(skip_all)] pub async fn list_email_drafts(state: State<'_, Arc>) -> Result, ApiError> { let drafts = state.emails.list_drafts(DESKTOP_USER_ID).await?; Ok(drafts.into_iter().map(EmailResponse::from).collect()) } /// Send a draft: send via SMTP, delete the draft record, save as sent. #[tauri::command] #[instrument(skip_all)] pub async fn send_email_draft(state: State<'_, Arc>, id: EmailId) -> Result { let draft = state.emails.get_by_id(id, DESKTOP_USER_ID).await? .or_not_found("draft", id)?; if !draft.is_draft { return Err(ApiError::bad_request("Email is not a draft")); } let account_id = draft.draft_account_id .ok_or_else(|| ApiError::validation_msg("Draft has no account selected"))?; // Build send input from draft fields let input = SendEmailInput { account_id, to_address: draft.to.clone(), cc_address: draft.cc_address.clone(), bcc_address: draft.bcc_address.clone(), subject: draft.subject.clone(), body: draft.body.clone(), project_id: draft.project_id, in_reply_to: draft.in_reply_to.clone(), references: None, thread_id: draft.thread_id.clone(), attachment_paths: Vec::new(), }; // Send via the existing send_email logic let result = send_email_inner(&state, input).await?; // Delete the draft state.emails.delete(id, DESKTOP_USER_ID).await?; Ok(result) } /// Sends an email via SMTP and saves a copy locally. #[tauri::command] #[instrument(skip_all)] pub async fn send_email(state: State<'_, Arc>, input: SendEmailInput) -> Result { send_email_inner(&state, input).await } /// Core send logic shared by `send_email` and `send_email_draft`. /// /// Flow: (1) validate inputs, (2) look up account + credentials, /// (3) send via SMTP (OAuth or password), (4) save outgoing record to /// the local database with `is_outgoing=true` and `source_folder="Sent"`. async fn send_email_inner(state: &Arc, input: SendEmailInput) -> Result { if input.subject.trim().is_empty() { return Err(ApiError::validation("subject", "Subject is required")); } if input.to_address.trim().is_empty() { return Err(ApiError::validation("toAddress", "At least one recipient is required")); } let account = state.email_accounts .get_by_id(input.account_id, DESKTOP_USER_ID) .await? .or_not_found("emailAccount", input.account_id)?; // Read attachment files use crate::email::smtp_client::AttachmentFile; let mut attachment_files = Vec::new(); for path_str in &input.attachment_paths { let path = std::path::Path::new(path_str); if !path.is_file() { return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str))); } let data = tokio::fs::read(path).await .map_api_err("Failed to read attachment file", ApiError::internal)?; let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("attachment") .to_string(); let mime_type = goingson_core::mime_from_extension(&filename).to_string(); attachment_files.push(AttachmentFile { filename, mime_type, data }); } let params = crate::email::smtp_client::SendParams { to: &input.to_address, cc: input.cc_address.as_deref(), bcc: input.bcc_address.as_deref(), subject: &input.subject, body: &input.body, in_reply_to: input.in_reply_to.as_deref(), references: input.references.as_deref(), attachments: attachment_files, }; let message_id = if uses_jmap(&account) { return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account")); } else if uses_oauth_imap(&account) { let access_token = get_valid_access_token(&state, &account).await?; let smtp_client = SmtpClient::with_oauth( &account.smtp_server, account.smtp_port as u16, &account.email_address, &access_token, true, ); smtp_client .send_message(¶ms) .await .map_api_err("Failed to send email", ApiError::external_service)? } else { let password = get_account_password(&account)?; let smtp_client = SmtpClient::with_password(&account, &password); smtp_client .send_message(¶ms) .await .map_api_err("Failed to send email", ApiError::external_service)? }; // For replies, join the existing thread. For new emails, start a new thread. let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone()); // Capture recipient addresses before they're moved into new_email let to_for_contacts = input.to_address.clone(); let cc_for_contacts = input.cc_address.clone(); let bcc_for_contacts = input.bcc_address.clone(); let new_email = NewEmailWithTracking { project_id: input.project_id, from_address: account.email_address.clone(), to_address: input.to_address, subject: input.subject, body: input.body, html_body: None, is_read: true, is_archived: false, received_at: Some(Utc::now()), message_id: Some(message_id.clone()), in_reply_to: input.in_reply_to, thread_id: Some(thread_id), imap_uid: None, source_folder: Some("Sent".to_string()), email_account_id: Some(input.account_id), is_outgoing: true, attachment_meta: None, }; let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?; // Auto-create implicit contacts for unknown recipients let new_implicit_contacts = create_implicit_contacts( state, &to_for_contacts, cc_for_contacts.as_deref(), bcc_for_contacts.as_deref(), &account.email_address, ).await; Ok(SendEmailResponse { success: true, message_id: Some(message_id), saved_email: EmailResponse::from(saved), new_implicit_contacts, }) } /// Create implicit contacts for recipients that don't have an existing contact. /// Errors are logged and swallowed — never fails the send. async fn create_implicit_contacts( state: &std::sync::Arc, to: &str, cc: Option<&str>, bcc: Option<&str>, sender_email: &str, ) -> Vec { use goingson_db_sqlite::utils::is_valid_email; use goingson_core::{NewContact, NewContactEmail}; use std::collections::HashSet; // Collect all unique recipient addresses let mut addresses = HashSet::new(); for field in [Some(to), cc, bcc].into_iter().flatten() { for addr in field.split(',').map(str::trim).filter(|a| !a.is_empty()) { let lower = addr.to_lowercase(); if lower != sender_email.to_lowercase() && is_valid_email(addr) { addresses.insert((addr.to_string(), lower)); } } } let mut new_contacts = Vec::new(); for (addr, _lower) in addresses { // Check if a contact already exists for this address match state.contacts.find_by_email(DESKTOP_USER_ID, &addr).await { Ok(Some(_)) => continue, // already exists Ok(None) => {} // proceed to create Err(e) => { tracing::warn!("Failed to check contact for {}: {}", addr, e); continue; } } // Derive display name from the local part of the email address let display_name = addr.split('@').next().unwrap_or(&addr) .replace('.', " ") .replace('_', " ") .split_whitespace() .map(|w| { let mut c = w.chars(); match c.next() { None => String::new(), Some(f) => f.to_uppercase().to_string() + c.as_str(), } }) .collect::>() .join(" "); let new_contact = NewContact { display_name, nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: true, }; match state.contacts.create(DESKTOP_USER_ID, new_contact).await { Ok(contact) => { let email_entry = NewContactEmail { address: addr.clone(), label: String::new(), is_primary: true, }; if let Err(e) = state.contacts.add_email(contact.id, DESKTOP_USER_ID, email_entry).await { tracing::warn!("Failed to add email to implicit contact: {}", e); } // Re-fetch to get hydrated contact with email sub-collection match state.contacts.get_by_id(contact.id, DESKTOP_USER_ID).await { Ok(Some(c)) => new_contacts.push(super::ContactResponse::from(c)), _ => new_contacts.push(super::ContactResponse::from(contact)), } } Err(e) => { tracing::warn!("Failed to create implicit contact for {}: {}", addr, e); } } } new_contacts } /// Set labels on an email. #[tauri::command] #[instrument(skip_all)] pub async fn set_email_labels(state: State<'_, Arc>, id: EmailId, labels: Vec) -> Result { let email = state.emails .update_labels(id, DESKTOP_USER_ID, &labels) .await? .or_not_found("email", id)?; Ok(EmailResponse::from(email)) } /// List distinct source folders across all emails. #[tauri::command] #[instrument(skip_all)] pub async fn list_email_folders(state: State<'_, Arc>) -> Result, ApiError> { Ok(state.emails.list_folders(DESKTOP_USER_ID).await?) } /// List all distinct labels used across emails. #[tauri::command] #[instrument(skip_all)] pub async fn list_email_labels(state: State<'_, Arc>) -> Result, ApiError> { Ok(state.emails.list_labels(DESKTOP_USER_ID).await?) } /// Move an email to a different IMAP folder. #[tauri::command] #[instrument(skip_all)] pub async fn move_email_to_folder( state: State<'_, Arc>, id: EmailId, folder: String, ) -> Result { let email = state.emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; let current_folder = email.source_folder.as_deref().unwrap_or("INBOX"); // Attempt IMAP move if the email has an account and UID if let (Some(account_id), Some(uid)) = (email.email_account_id, email.imap_uid) { if let Some((imap_client, _)) = build_imap_client(&state, account_id, "move_to_folder").await { if let Err(e) = imap_client.move_message(uid as u32, current_folder, &folder).await { warn!("IMAP move failed (local update will proceed): {}", e); } } } // Always update locally Ok(state.emails.update_source_folder(id, DESKTOP_USER_ID, &folder).await?) } /// Deletes an email. #[tauri::command] #[instrument(skip_all)] pub async fn delete_email(state: State<'_, Arc>, id: EmailId) -> Result { Ok(state.emails.delete(id, DESKTOP_USER_ID).await?) } /// Marks an email as read. #[tauri::command] #[instrument(skip_all)] pub async fn mark_email_read(state: State<'_, Arc>, id: EmailId) -> Result { Ok(state.emails.mark_read(id, DESKTOP_USER_ID).await?) } /// Marks an email as unread. #[tauri::command] #[instrument(skip_all)] pub async fn mark_email_unread(state: State<'_, Arc>, id: EmailId) -> Result { Ok(state.emails.mark_unread(id, DESKTOP_USER_ID).await?) } /// Archives an email locally and on the IMAP server. /// /// If the email has an associated IMAP account and UID, attempts to move /// the message to the server's Archive folder via IMAP MOVE. If the IMAP /// move fails (e.g. server unreachable), the local archive still proceeds /// — the next sync will reconcile the mismatch. #[tauri::command] #[instrument(skip_all)] pub async fn archive_email(state: State<'_, Arc>, id: EmailId) -> Result { let email = state.emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid) { if let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "archive").await { if let Err(e) = imap_client.archive_message(imap_uid as u32, &archive_folder).await { warn!("Failed to archive on IMAP server: {}", e); } } } Ok(state.emails.archive(id, DESKTOP_USER_ID).await?) } /// Unarchives an email locally and moves it back to INBOX on the IMAP server. /// /// Same best-effort IMAP sync as `archive_email` — local unarchive always /// succeeds even if the server operation fails. #[tauri::command] #[instrument(skip_all)] pub async fn unarchive_email(state: State<'_, Arc>, id: EmailId) -> Result { let email = state.emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; if let (Some(account_id), Some(imap_uid)) = (email.email_account_id, email.imap_uid) { if let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "unarchive").await { if let Err(e) = imap_client.unarchive_message(imap_uid as u32, &archive_folder).await { warn!("Failed to unarchive on IMAP server: {}", e); } } } Ok(state.emails.unarchive(id, DESKTOP_USER_ID).await?) } /// Marks all emails as read. #[tauri::command] #[instrument(skip_all)] pub async fn mark_all_emails_read(state: State<'_, Arc>) -> Result { Ok(state.emails.mark_all_read(DESKTOP_USER_ID).await?) } /// Links an email to a project. #[tauri::command] #[instrument(skip_all)] pub async fn link_email_to_project(state: State<'_, Arc>, id: EmailId, input: LinkProjectInput) -> Result { Ok(state.emails.link_to_project(id, DESKTOP_USER_ID, input.project_id).await?) } /// Gets the count of unread emails. #[tauri::command] #[instrument(skip_all)] pub async fn get_unread_email_count(state: State<'_, Arc>) -> Result { let count = state.emails.count_unread(DESKTOP_USER_ID).await?; Ok(UnreadCountResponse { count }) } /// Lists all snoozed emails. #[tauri::command] #[instrument(skip_all)] pub async fn list_snoozed_emails(state: State<'_, Arc>) -> Result, ApiError> { let emails = state.emails.list_snoozed(DESKTOP_USER_ID).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) } /// Snoozes an email until a specified time. #[tauri::command] #[instrument(skip_all)] pub async fn snooze_email(state: State<'_, Arc>, id: EmailId, input: SnoozeInput) -> Result { let email = state.emails .snooze(id, DESKTOP_USER_ID, input.until) .await? .or_not_found("email", id)?; Ok(EmailResponse::from(email)) } /// Unsnoozes an email. #[tauri::command] #[instrument(skip_all)] pub async fn unsnooze_email(state: State<'_, Arc>, id: EmailId) -> Result { let email = state.emails .unsnooze(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; Ok(EmailResponse::from(email)) } /// Lists all emails marked as waiting for response. #[tauri::command] #[instrument(skip_all)] pub async fn list_waiting_emails(state: State<'_, Arc>) -> Result, ApiError> { let emails = state.emails.list_waiting(DESKTOP_USER_ID).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) } /// Marks an email as waiting for response. #[tauri::command] #[instrument(skip_all)] pub async fn mark_email_waiting(state: State<'_, Arc>, id: EmailId, input: WaitingInput) -> Result { let email = state.emails .mark_waiting(id, DESKTOP_USER_ID, input.expected_response_date) .await? .or_not_found("email", id)?; Ok(EmailResponse::from(email)) } /// Clears the waiting status from an email. #[tauri::command] #[instrument(skip_all)] pub async fn clear_email_waiting(state: State<'_, Arc>, id: EmailId) -> Result { let email = state.emails .clear_waiting(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; Ok(EmailResponse::from(email)) } // ============ Project Dashboard Commands ============ /// Lists all emails for a specific project. #[tauri::command] #[instrument(skip_all)] pub async fn list_emails_for_project(state: State<'_, Arc>, project_id: ProjectId) -> Result, ApiError> { let emails = state.emails.list_by_project(DESKTOP_USER_ID, project_id).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) } /// Lists emails not linked to any project. #[tauri::command] #[instrument(skip_all)] pub async fn list_unlinked_emails(state: State<'_, Arc>) -> Result, ApiError> { let emails = state.emails.list_unlinked(DESKTOP_USER_ID).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) } /// Lists all emails in a thread. #[tauri::command] #[instrument(skip_all)] pub async fn list_emails_by_thread(state: State<'_, Arc>, thread_id: String) -> Result, ApiError> { let emails = state.emails.list_by_thread(DESKTOP_USER_ID, &thread_id).await?; Ok(emails.into_iter().map(EmailResponse::from).collect()) }