//! Email account management commands. //! //! Provides CRUD operations for email account configurations (IMAP/SMTP/JMAP/OAuth). //! Auth helpers (`get_account_password`, `get_valid_access_token`, `uses_oauth_imap`) //! are `pub(super)` so the sync and email modules can use them. use chrono::{Local, Utc}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use uuid::Uuid; use tracing::instrument; use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType}; use crate::email::{uses_jmap, ImapClient, SmtpClient}; use crate::jmap::JmapClient; use crate::oauth::{CredentialStore, TokenManager}; use crate::state::{AppState, DESKTOP_USER_ID}; use goingson_db_sqlite::utils::is_valid_email; use super::{ApiError, OptionApiError, OptionNotFound, ResultApiError}; // ============ Auth Helpers (pub(super)) ============ /// Returns true if the account uses OAuth with IMAP (not JMAP). pub(super) fn uses_oauth_imap(account: &EmailAccount) -> bool { account.is_oauth() && !uses_jmap(account) } /// Gets the password for a password-based email account. pub(super) fn get_account_password(account: &EmailAccount) -> Result { if !account.password.is_empty() { return Ok(account.password.clone()); } // Migration: check keychain for passwords stored by older versions let raw_id: Uuid = account.id.into(); if let Some(password) = CredentialStore::get_password(raw_id) { return Ok(password); } Err(ApiError::auth("No password available")) } /// Gets a valid access token for an OAuth account, refreshing if needed. pub(super) async fn get_valid_access_token( state: &Arc, account: &EmailAccount, ) -> Result { let raw_id: Uuid = account.id.into(); // Get access token from keychain (fall back to database for migration) let access_token = CredentialStore::get_oauth(raw_id) .map(|c| c.access_token) .or_else(|| account.oauth2_access_token.clone()) .or_api_err(|| ApiError::auth("No access token available"))?; // Check if token needs refresh (serialized per-account to prevent thundering herd) if account.needs_token_refresh() { let refresh_lock = state.token_refresh_lock(raw_id); let _guard = refresh_lock.lock().await; let token_manager = TokenManager::from_env(); match token_manager.refresh_if_needed(account).await .map_api_err("Token refresh failed", ApiError::auth)? { Some((new_access_token, new_refresh_token, expires_at)) => { // Update expiration in database (not tokens) state .email_accounts .update_oauth_tokens( account.id, DESKTOP_USER_ID, "", // Don't store in DB None, expires_at, ) .await?; // Store new tokens in keychain CredentialStore::update_oauth_tokens( raw_id, &new_access_token, new_refresh_token.as_deref(), ) .map_api_err("Failed to store tokens", ApiError::internal)?; Ok(new_access_token) } None => Ok(access_token), } } else { Ok(access_token) } } // ============ Types ============ /// Email account response with pre-computed fields for UI. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EmailAccountResponse { pub id: EmailAccountId, pub account_name: String, pub email_address: String, pub imap_server: String, pub imap_port: i32, pub smtp_server: String, pub smtp_port: i32, pub username: String, pub use_tls: bool, pub last_sync_at: Option>, pub created_at: chrono::DateTime, pub archive_folder_name: Option, pub auth_type: EmailAuthType, pub oauth2_token_expires_at: Option>, pub sync_interval_minutes: Option, pub email_signature: Option, pub notify_new_emails: bool, // Pre-computed fields /// Human-readable last sync time: "Just now", "5m ago", "2h ago", "Never synced" pub last_sync_formatted: String, } impl From for EmailAccountResponse { fn from(a: EmailAccount) -> Self { let last_sync_formatted = match a.last_sync_at { None => "Never synced".to_string(), Some(sync_at) => { let now = Utc::now(); let diff = now.signed_duration_since(sync_at); let mins = diff.num_minutes(); if mins < 1 { "Just now".to_string() } else if mins < 60 { format!("{}m ago", mins) } else { let hours = diff.num_hours(); if hours < 24 { format!("{}h ago", hours) } else { sync_at.with_timezone(&Local).format("%b %d").to_string() } } } }; EmailAccountResponse { id: a.id, account_name: a.account_name, email_address: a.email_address, imap_server: a.imap_server, imap_port: a.imap_port, smtp_server: a.smtp_server, smtp_port: a.smtp_port, username: a.username, use_tls: a.use_tls, last_sync_at: a.last_sync_at, created_at: a.created_at, archive_folder_name: a.archive_folder_name, auth_type: a.auth_type, oauth2_token_expires_at: a.oauth2_token_expires_at, sync_interval_minutes: a.sync_interval_minutes, email_signature: a.email_signature, notify_new_emails: a.notify_new_emails, last_sync_formatted, } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailAccountInput { pub account_name: String, pub email_address: String, pub imap_server: String, pub imap_port: i32, pub smtp_server: String, pub smtp_port: i32, pub username: String, pub password: String, pub use_tls: bool, pub archive_folder_name: Option, pub sync_interval_minutes: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailAccountUpdateInput { pub account_name: String, pub email_address: String, pub imap_server: String, pub imap_port: i32, pub smtp_server: String, pub smtp_port: i32, pub username: String, pub password: Option, pub use_tls: bool, pub archive_folder_name: Option, pub sync_interval_minutes: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncIntervalInput { pub sync_interval_minutes: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SignatureInput { pub email_signature: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TestConnectionResponse { pub imap_success: bool, pub imap_message: String, pub smtp_success: bool, pub smtp_message: String, pub available_folders: Vec, } // ============ Commands ============ /// Lists all email accounts for the current user. #[tauri::command] #[instrument(skip_all)] pub async fn list_email_accounts(state: State<'_, Arc>) -> Result, ApiError> { let accounts = state.email_accounts.list_by_user(DESKTOP_USER_ID).await?; Ok(accounts.into_iter().map(EmailAccountResponse::from).collect()) } /// Retrieves a single email account by ID. #[tauri::command] #[instrument(skip_all)] pub async fn get_email_account(state: State<'_, Arc>, id: EmailAccountId) -> Result, ApiError> { Ok(state.email_accounts.get_by_id(id, DESKTOP_USER_ID).await?) } /// Creates a new email account with IMAP/SMTP credentials. #[tauri::command] #[instrument(skip_all)] pub async fn create_email_account(state: State<'_, Arc>, input: EmailAccountInput) -> Result { if input.account_name.trim().is_empty() { return Err(ApiError::validation("accountName", "Account name is required")); } if !is_valid_email(&input.email_address) { return Err(ApiError::validation("emailAddress", "Invalid email address")); } if input.imap_server.trim().is_empty() { return Err(ApiError::validation("imapServer", "IMAP server is required")); } if input.smtp_server.trim().is_empty() { return Err(ApiError::validation("smtpServer", "SMTP server is required")); } if let Some(ref folder) = input.archive_folder_name { if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) { return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters")); } } let account = state.email_accounts .create( DESKTOP_USER_ID, &input.account_name, &input.email_address, &input.imap_server, input.imap_port, &input.smtp_server, input.smtp_port, &input.username, &input.password, input.use_tls, input.archive_folder_name.as_deref(), ) .await?; Ok(account) } /// Updates an existing email account. #[tauri::command] #[instrument(skip_all)] pub async fn update_email_account(state: State<'_, Arc>, id: EmailAccountId, input: EmailAccountUpdateInput) -> Result { if input.account_name.trim().is_empty() { return Err(ApiError::validation("accountName", "Account name is required")); } if !is_valid_email(&input.email_address) { return Err(ApiError::validation("emailAddress", "Invalid email address")); } if let Some(ref folder) = input.archive_folder_name { if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) { return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters")); } } state.email_accounts .update( id, DESKTOP_USER_ID, &input.account_name, &input.email_address, &input.imap_server, input.imap_port, &input.smtp_server, input.smtp_port, &input.username, input.password.as_deref(), input.use_tls, input.archive_folder_name.as_deref(), ) .await? .or_not_found("emailAccount", id) } /// Deletes an email account. Also removes credentials from OS keychain. #[tauri::command] #[instrument(skip_all)] pub async fn delete_email_account(state: State<'_, Arc>, id: EmailAccountId) -> Result { let raw_id: Uuid = id.into(); let _ = CredentialStore::delete_oauth(raw_id); Ok(state.email_accounts.delete(id, DESKTOP_USER_ID).await?) } /// Updates the sync interval for an email account. #[tauri::command] #[instrument(skip_all)] pub async fn update_email_sync_interval( state: State<'_, Arc>, id: EmailAccountId, input: SyncIntervalInput, ) -> Result { state.email_accounts .update_sync_interval(id, DESKTOP_USER_ID, input.sync_interval_minutes) .await? .or_not_found("emailAccount", id) } /// Updates the email signature for an account. #[tauri::command] #[instrument(skip_all)] pub async fn update_email_signature( state: State<'_, Arc>, id: EmailAccountId, input: SignatureInput, ) -> Result { let sig = input.email_signature.filter(|s| !s.trim().is_empty()); let account = state.email_accounts .update_signature(id, DESKTOP_USER_ID, sig.as_deref()) .await? .or_not_found("emailAccount", id)?; Ok(account.into()) } /// Updates the notification preference for an email account. #[tauri::command] #[instrument(skip_all)] pub async fn update_email_notify( state: State<'_, Arc>, id: EmailAccountId, enabled: bool, ) -> Result { let account = state.email_accounts .update_notify_new_emails(id, DESKTOP_USER_ID, enabled) .await? .or_not_found("emailAccount", id)?; Ok(account.into()) } /// Tests an email account's IMAP and SMTP connections. #[tauri::command] #[instrument(skip_all)] pub async fn test_email_account(state: State<'_, Arc>, id: EmailAccountId) -> Result { let account = state.email_accounts .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("emailAccount", id)?; // Handle OAuth/JMAP accounts differently if uses_jmap(&account) { return test_jmap_account(&account).await; } if uses_oauth_imap(&account) { return test_oauth_imap_account(&state, &account).await; } // Password-based IMAP/SMTP let password = get_account_password(&account)?; let imap_client = ImapClient::with_password(&account, &password); let (imap_success, imap_message, available_folders) = match imap_client.test_connection().await { Ok(()) => { let folders = imap_client.list_folders().await.unwrap_or_default(); (true, "IMAP connection successful".to_string(), folders) } Err(e) => (false, format!("IMAP error: {}", e), Vec::new()), }; let smtp_client = SmtpClient::with_password(&account, &password); let (smtp_success, smtp_message) = match smtp_client.test_connection().await { Ok(()) => (true, "SMTP connection successful".to_string()), Err(e) => (false, format!("SMTP error: {}", e)), }; Ok(TestConnectionResponse { imap_success, imap_message, smtp_success, smtp_message, available_folders, }) } /// Tests an OAuth IMAP account connection using XOAUTH2. async fn test_oauth_imap_account( state: &Arc, account: &EmailAccount, ) -> Result { let access_token = get_valid_access_token(state, account).await?; let imap_client = ImapClient::with_oauth( &account.imap_server, account.imap_port as u16, &account.email_address, &access_token, ); let (imap_success, imap_message, available_folders) = match imap_client.test_connection().await { Ok(()) => { let folders = imap_client.list_folders().await.unwrap_or_default(); (true, "IMAP XOAUTH2 connection successful".to_string(), folders) } Err(e) => { let is_auth_error = e.contains("AUTH") || e.contains("auth") || e.contains("AUTHENTICATE"); let message = if is_auth_error { "XOAUTH2 authentication failed - please reconnect your account".to_string() } else { format!("IMAP error: {}", e) }; (false, message, Vec::new()) } }; let smtp_client = SmtpClient::with_oauth( &account.smtp_server, account.smtp_port as u16, &account.email_address, &access_token, true, ); let (smtp_success, smtp_message) = match smtp_client.test_connection().await { Ok(()) => (true, "SMTP XOAUTH2 connection successful".to_string()), Err(e) => (false, format!("SMTP error: {}", e)), }; Ok(TestConnectionResponse { imap_success, imap_message, smtp_success, smtp_message, available_folders, }) } /// Tests a JMAP account connection. async fn test_jmap_account(account: &EmailAccount) -> Result { let session_url = account.jmap_session_url.as_ref() .or_api_err(|| ApiError::bad_request("No JMAP session URL configured"))?; let access_token = CredentialStore::get_oauth(account.id.into()) .map(|c| c.access_token) .or_else(|| account.oauth2_access_token.clone()) .or_api_err(|| ApiError::auth("No access token available"))?; let mut client = JmapClient::new(session_url, &access_token) .map_err(ApiError::external_service)?; let session_result = client.session().await; let username = match &session_result { Ok(session) => session.username.clone(), Err(e) => { let is_auth_error = e.contains("401") || e.contains("unauthorized") || e.contains("Unauthorized"); let message = if is_auth_error { "Authentication failed - please reconnect your account".to_string() } else { format!("JMAP error: {}", e) }; return Ok(TestConnectionResponse { imap_success: false, imap_message: message, smtp_success: false, smtp_message: "Cannot test - session failed".to_string(), available_folders: Vec::new(), }); } }; let folders = match client.list_mailboxes().await { Ok(mailboxes) => mailboxes.into_iter().map(|m| m.name).collect(), Err(_) => Vec::new(), }; Ok(TestConnectionResponse { imap_success: true, imap_message: format!("JMAP session OK - connected as {}", username), smtp_success: true, smtp_message: "JMAP Submission available".to_string(), available_folders: folders, }) }