//! 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 super::email_account::{get_account_password, get_valid_access_token, uses_oauth_imap}; use super::{ ApiError, LinkProjectInput, OptionApiError, OptionNotFound, ResultApiError, SnoozeInput, WaitingInput, }; use crate::email::{ImapClient, SmtpClient, uses_jmap}; use crate::state::{AppState, DESKTOP_USER_ID}; use goingson_core::date_utils::{format_elapsed_time, format_relative_future}; use goingson_db_sqlite::utils::is_valid_email; mod contacts; mod preview; mod send; pub use preview::*; use send::{build_imap_client, send_email_inner}; // IMAP Client Helper // 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, /// Char-safe ~100-char plaintext preview for the list view (Rust does the slice). pub body_preview: String, /// Whether the body was truncated at sync and can be re-fetched in full. pub body_truncated: bool, 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); // Char-boundary-safe preview (byte slicing would panic on multibyte content). let body_preview: String = e.body.chars().take(100).collect(); 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, body_preview, body_truncated: e.body_truncated, 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> { // Body-less + capped: this flat list never renders a body (the reader fetches it // via get_by_id), so it must not materialize every body at once (Perf S3). let emails = state .emails .list_metadata(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)) } /// Prefill for the compose window when replying or forwarding. /// /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting) /// is computed in Rust so the frontend just renders the result. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ComposePrefillResponse { pub to: String, pub subject: String, pub body: String, pub in_reply_to: Option, pub references: Option, pub thread_id: Option, /// Account to send from ("" when the source email has no account). pub account_id: String, } /// Format an email timestamp for a reply/forward attribution line, in local time. fn format_attribution_date(dt: chrono::DateTime) -> String { dt.with_timezone(&chrono::Local) .format("%b %-d, %Y, %-I:%M %p") .to_string() } /// Builds the compose prefill for replying to an email. /// /// Reply-all assembles sender + original recipients, excluding the user's own /// account addresses (authoritative, from the account repo, not a browser /// cache) and de-duplicating. #[tauri::command] #[instrument(skip_all)] pub async fn build_reply_prefill( state: State<'_, Arc>, id: EmailId, reply_all: bool, ) -> Result { let email = state .emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; let own_addresses: Vec = state .email_accounts .list_by_user(DESKTOP_USER_ID) .await? .into_iter() .map(|a| a.email_address) .collect(); let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all); let subject = goingson_core::reply_subject(&email.subject); let date = format_attribution_date(email.received_at); let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body); Ok(ComposePrefillResponse { to, subject, body, in_reply_to: email.message_id.clone(), references: email.message_id, thread_id: email.thread_id, account_id: email .email_account_id .map(|a| a.to_string()) .unwrap_or_default(), }) } /// Builds the compose prefill for forwarding an email. #[tauri::command] #[instrument(skip_all)] pub async fn build_forward_prefill( state: State<'_, Arc>, id: EmailId, ) -> Result { let email = state .emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; let subject = goingson_core::forward_subject(&email.subject); let date = format_attribution_date(email.received_at); let body = goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body); Ok(ComposePrefillResponse { to: String::new(), subject, body, in_reply_to: None, references: None, thread_id: None, account_id: email .email_account_id .map(|a| a.to_string()) .unwrap_or_default(), }) } /// Fetches the full body of a JMAP email whose body was truncated at sync /// (>100KB). Re-fetches via the provider, stores the full body, clears the /// truncation flag, and returns the body. If the email was not truncated, the /// already-stored body is returned unchanged. #[tauri::command] #[instrument(skip_all)] pub async fn fetch_email_full_body( state: State<'_, Arc>, id: EmailId, ) -> Result { let email = state .emails .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("email", id)?; if !email.body_truncated { return Ok(email.body); } let jmap_id = email .jmap_id .or_api_err(|| ApiError::bad_request("Email has no JMAP id; full body unavailable"))?; let account_id = email .email_account_id .or_api_err(|| ApiError::bad_request("Email has no account; cannot re-fetch body"))?; let account = state .email_accounts .get_by_id(account_id, DESKTOP_USER_ID) .await? .or_not_found("emailAccount", account_id)?; let mut client = super::email_sync::build_jmap_client(state.inner(), &account).await?; let body = client.fetch_email_full_body(&jmap_id).await.map_api_err( "Failed to fetch full email body", ApiError::external_service, )?; state .emails .set_full_body(id, DESKTOP_USER_ID, &body) .await?; Ok(body) } /// 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_default(); 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 } /// 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) && let Some((imap_client, _)) = build_imap_client(&state, account_id, "move_to_folder").await && 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) && let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "archive").await && 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) && let Some((imap_client, archive_folder)) = build_imap_client(&state, account_id, "unarchive").await && 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()) }