//! Email send service: build the IMAP client for mailbox mutations, and the //! SMTP send flow (validate, credentials, send, persist the outgoing record). //! Split out of the email command module. use tracing::warn; use super::contacts::create_implicit_contacts; use super::{ ApiError, AppState, Arc, DESKTOP_USER_ID, EmailAccountId, EmailResponse, ImapClient, NewEmailWithTracking, OptionNotFound, ResultApiError, SendEmailInput, SendEmailResponse, SmtpClient, Utc, get_account_password, get_valid_access_token, uses_jmap, uses_oauth_imap, }; /// 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. pub(super) async fn build_imap_client( state: &Arc, account_id: EmailAccountId, operation: &str, ) -> Option<(ImapClient, String)> { let Ok(Some(account)) = state .email_accounts .get_by_id(account_id, DESKTOP_USER_ID) .await else { 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)) } /// 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"`. pub(super) 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. Cap the combined size before buffering them all into // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up // memory or get rejected by the server after a long upload (Perf minor). const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; use crate::email::smtp_client::AttachmentFile; let mut attachment_files = Vec::new(); let mut total_bytes: u64 = 0; 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 meta = tokio::fs::metadata(path) .await .map_api_err("Failed to read attachment file", ApiError::internal)?; total_bytes = total_bytes.saturating_add(meta.len()); if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES { return Err(ApiError::validation( "attachmentPaths", format!( "Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024) ), )); } 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, ); 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, body_truncated: false, jmap_id: 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, }) }