use std::path::Path; use std::sync::Arc; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; use crate::auth::session::SessionState; use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::imap::client::{ImapClient, ImapCredentials}; use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials}; use crate::smtp::types::{PgpMode, PgpSendConfig}; #[derive(Deserialize)] pub struct SendRequest { pub to: Vec, #[serde(default)] pub cc: Vec, #[serde(default)] pub bcc: Vec, #[serde(default)] pub subject: String, pub text_body: Option, pub html_body: Option, pub in_reply_to: Option, pub references: Option, pub draft_id: Option, pub from_identity_id: Option, #[serde(default)] pub request_mdn: bool, #[serde(default = "default_priority")] pub priority: String, pub pgp_mode: Option, pub pgp_inline: Option, pub pgp_passphrase: Option, #[serde(default)] pub pgp_attach_public_key: bool, #[serde(default)] pub pgp_attach_recipient_keys: bool, } fn default_priority() -> String { "normal".to_string() } fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> { value .parse::() .map(|_| ()) .map_err(|_| AppError::BadRequest(format!("Invalid {label} address: {value}"))) } #[derive(Debug, Serialize)] pub struct SendResponse { pub status: String, pub message_id: String, } pub async fn send_message_handler( Extension(session): Extension, Extension(config): Extension>, Extension(smtp_client): Extension>, Extension(imap_client): Extension>, Json(req): Json, ) -> Result { if let Some(ref draft_id) = req.draft_id { db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?; } if req.to.is_empty() && req.cc.is_empty() && req.bcc.is_empty() { return Err(AppError::BadRequest( "At least one recipient is required".to_string(), )); } let has_subject = !req.subject.trim().is_empty(); let has_text = req .text_body .as_deref() .is_some_and(|t| !t.trim().is_empty()); let has_html = req .html_body .as_deref() .is_some_and(|h| !h.trim().is_empty()); if !has_subject && !has_text && !has_html { return Err(AppError::BadRequest( "Subject or body is required".to_string(), )); } let priority = req.priority.trim().to_ascii_lowercase(); if !matches!(priority.as_str(), "high" | "normal" | "low") { return Err(AppError::BadRequest( "Priority must be high, normal, or low".to_string(), )); } if session.smtp_host.trim().is_empty() { return Err(AppError::ServiceUnavailable( "SMTP server is not configured for this account".to_string(), )); } let smtp_creds = SmtpCredentials { host: session.smtp_host.clone(), port: session.smtp_port, tls: session.smtp_tls, tls_verify: session.smtp_tls_verify, email: session.email.clone(), password: session.password.clone(), }; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let selected_identity = if let Some(identity_id) = req.from_identity_id { Some( db::identities::get_identity(&conn, identity_id) .map_err(AppError::InternalError)? .ok_or_else(|| AppError::NotFound("Identity not found".to_string()))?, ) } else { db::identities::get_default_identity(&conn).map_err(AppError::InternalError)? }; let receipt_address = selected_identity .as_ref() .map(|identity| identity.email.clone()) .unwrap_or_else(|| session.email.clone()); let from_address = selected_identity .as_ref() .map(|identity| { if identity.display_name.is_empty() { identity.email.clone() } else { let display_name = identity .display_name .replace('\\', "\\\\") .replace('\"', "\\\""); format!("\"{display_name}\" <{}>", identity.email) } }) .unwrap_or_else(|| session.email.clone()); let reply_to = selected_identity.as_ref().and_then(|identity| { let value = identity.reply_to.trim(); if value.is_empty() { None } else { Some(value.to_string()) } }); let organization = selected_identity.as_ref().and_then(|identity| { let value = identity.organization.trim(); if value.is_empty() { None } else { Some(value.to_string()) } }); let mut bcc = req.bcc; if let Some(identity) = selected_identity.as_ref() { for address in identity .bcc .split(',') .map(str::trim) .filter(|value| !value.is_empty()) { if !bcc .iter() .any(|existing| existing.eq_ignore_ascii_case(address)) { bcc.push(address.to_string()); } } } let pgp_mode = resolve_pgp_mode(req.pgp_mode.as_deref(), selected_identity.as_ref())?; if !config.pgp_enabled && (pgp_mode != PgpMode::None || req.pgp_attach_public_key || req.pgp_attach_recipient_keys) { return Err(AppError::ServiceUnavailable( "OpenPGP is disabled by administrator policy".to_string(), )); } let pgp_inline = req.pgp_inline.unwrap_or_else(|| { selected_identity .as_ref() .is_some_and(|identity| identity.pgp_inline_default) }); let attached_public_key = if req.pgp_attach_public_key { Some(resolve_sender_public_key( &conn, selected_identity.as_ref(), &receipt_address, )?) } else { None }; let attached_recipient_keys = if req.pgp_attach_recipient_keys { resolve_recipient_public_keys(&conn, &req.to, &req.cc)? } else { Vec::new() }; let pgp = build_pgp_send_config( &conn, &config.data_dir, &session.user_hash, pgp_mode, pgp_inline, req.pgp_passphrase.as_deref().unwrap_or(""), selected_identity.as_ref(), &receipt_address, &req.to, &req.cc, &bcc, )?; drop(conn); validate_mailbox("From", &from_address)?; for address in &req.to { validate_mailbox("To", address)?; } for address in &req.cc { validate_mailbox("Cc", address)?; } for address in &bcc { validate_mailbox("Bcc", address)?; } if let Some(address) = reply_to.as_deref() { validate_mailbox("Reply-To", address)?; } if req.request_mdn { validate_mailbox("read receipt", &receipt_address)?; } let mut attachments = if let Some(ref draft_id) = req.draft_id { load_draft_attachments(&config.data_dir, &session.user_hash, draft_id)? } else { vec![] }; if let Some((key_id, armor)) = attached_public_key { attachments.push(AttachmentData { filename: format!("openpgp-{key_id}-public.asc"), content_type: "application/pgp-keys".to_string(), data: armor.into_bytes(), content_id: None, }); } for (key_id, email, armor) in attached_recipient_keys { let safe_email: String = email .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '@' | '.' | '-' | '_') { ch } else { '_' } }) .collect(); attachments.push(AttachmentData { filename: format!("openpgp-{key_id}-{safe_email}-public.asc"), content_type: "application/pgp-keys".to_string(), data: armor.into_bytes(), content_id: None, }); } let message = SendableMessage { from: from_address, to: req.to, cc: req.cc, bcc, reply_to, organization, subject: req.subject, text_body: req.text_body.unwrap_or_default(), html_body: req.html_body, in_reply_to: req.in_reply_to, references: req.references, read_receipt_to: req.request_mdn.then_some(receipt_address), priority, extra_headers: config .parsed_outgoing_headers() .map_err(AppError::InternalError)?, attachments, pgp, max_message_size_bytes: config.max_message_size_bytes(), }; let sent = smtp_client .send_message(&smtp_creds, &message) .await .map_err(|e| match e { crate::smtp::client::SmtpError::MessageTooLarge { actual, limit } => { AppError::PayloadTooLarge(format!( "Message is {:.2} MiB after MIME encoding; configured limit is {:.2} MiB", actual as f64 / 1024.0 / 1024.0, limit as f64 / 1024.0 / 1024.0 )) } other => AppError::ServiceUnavailable(format!("Failed to send email: {other}")), })?; let message_id = sent.message_id.clone(); let imap_creds = ImapCredentials { host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }; { let database = db::pool::UserDb::new(&config.data_dir, &session.user_hash); let sent_folder = database .run(|conn| { Ok(db::special_folders::get_role(conn, "sent")? .or(db::folders::find_folder_by_attribute(conn, "\\Sent")?) .unwrap_or_else(|| "Sent".to_string())) }) .await .unwrap_or_else(|_| "Sent".to_string()); match imap_client .append_message(&imap_creds, &sent_folder, &sent.rfc822, &["\\Seen"]) .await { Ok(()) => { let folder = sent_folder.clone(); if let Err(e) = database .run(move |conn| db::folders::invalidate_folder_freshness(conn, &folder)) .await { tracing::warn!(error = %e, folder = %sent_folder, "Failed to invalidate sent folder cache after IMAP append"); } } Err(e) => { tracing::warn!(error = %e, folder = %sent_folder, "Failed to append sent message to configured IMAP sent folder"); } } } if let Some(ref draft_id) = req.draft_id { if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash) && let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id) { crate::routes::drafts::remove_remote_draft_snapshot( imap_client.as_ref(), &imap_creds, &draft, ) .await; } cleanup_draft(&config.data_dir, &session.user_hash, draft_id).await; } Ok(Json(SendResponse { status: "sent".to_string(), message_id, }) .into_response()) } fn load_draft_attachments( data_dir: &str, user_hash: &str, draft_id: &str, ) -> Result, AppError> { let conn = db::pool::open_user_db(data_dir, user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let db_attachments = db::drafts::get_draft_attachments(&conn, draft_id).map_err(AppError::InternalError)?; let mut attachments = Vec::new(); for att in db_attachments { let data = std::fs::read(&att.file_path).map_err(|e| { AppError::InternalError(format!( "Failed to read attachment file '{}': {e}", att.filename )) })?; attachments.push(AttachmentData { filename: att.filename, content_type: att.content_type, data, content_id: Some(att.id), }); } Ok(attachments) } async fn cleanup_draft(data_dir: &str, user_hash: &str, draft_id: &str) { if let Ok(conn) = db::pool::open_user_db(data_dir, user_hash) && let Err(e) = db::drafts::delete_draft(&conn, draft_id) { tracing::warn!(error = %e, "Failed to delete draft after send"); } let att_dir = Path::new(data_dir) .join(user_hash) .join("attachments") .join(draft_id); if att_dir.exists() && let Err(e) = tokio::fs::remove_dir_all(&att_dir).await { tracing::warn!(error = %e, path = %att_dir.display(), "Failed to clean up attachment directory"); } } fn resolve_recipient_public_keys( conn: &crate::db::pool::Connection, to: &[String], cc: &[String], ) -> Result, AppError> { let mut resolved = Vec::new(); let mut seen = std::collections::HashSet::new(); for value in to.iter().chain(cc) { let email = mailbox_address(value)?; let Some(key) = db::pgp::find_key_internal_by_email(conn, &email).map_err(AppError::InternalError)? else { continue; }; if seen.insert(key.record.fingerprint.clone()) { resolved.push((key.record.key_id, email, key.record.public_key_armor)); } } if resolved.is_empty() { return Err(AppError::BadRequest( "No OpenPGP public key is available for any To/Cc recipient".to_string(), )); } Ok(resolved) } fn resolve_sender_public_key( conn: &crate::db::pool::Connection, identity: Option<&crate::db::identities::Identity>, sender_email: &str, ) -> Result<(String, String), AppError> { if let Some(fingerprint) = identity.and_then(|item| item.pgp_fingerprint.as_deref()) && let Some(key) = db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)? { return Ok((key.record.key_id, key.record.public_key_armor)); } let key = db::pgp::find_key_internal_by_email(conn, sender_email) .map_err(AppError::InternalError)? .ok_or_else(|| { AppError::BadRequest( "No OpenPGP public key is available for the sending identity".to_string(), ) })?; Ok((key.record.key_id, key.record.public_key_armor)) } fn resolve_pgp_mode( requested: Option<&str>, identity: Option<&db::identities::Identity>, ) -> Result { if let Some(value) = requested { return match value.trim().to_ascii_lowercase().as_str() { "" | "none" => Ok(PgpMode::None), "sign" => Ok(PgpMode::Sign), "encrypt" => Ok(PgpMode::Encrypt), "sign-encrypt" | "sign_encrypt" | "sign+encrypt" => Ok(PgpMode::SignEncrypt), _ => Err(AppError::BadRequest( "pgpMode must be none, sign, encrypt, or sign-encrypt".to_string(), )), }; } let Some(identity) = identity else { return Ok(PgpMode::None); }; Ok( match (identity.pgp_sign_default, identity.pgp_encrypt_default) { (true, true) => PgpMode::SignEncrypt, (true, false) => PgpMode::Sign, (false, true) => PgpMode::Encrypt, (false, false) => PgpMode::None, }, ) } #[allow(clippy::too_many_arguments)] fn build_pgp_send_config( conn: &crate::db::pool::Connection, data_dir: &str, user_hash: &str, mode: PgpMode, inline: bool, passphrase: &str, identity: Option<&db::identities::Identity>, sender_email: &str, to: &[String], cc: &[String], bcc: &[String], ) -> Result, AppError> { if mode == PgpMode::None { return Ok(None); } let needs_signing = matches!(mode, PgpMode::Sign | PgpMode::SignEncrypt); let needs_encryption = matches!(mode, PgpMode::Encrypt | PgpMode::SignEncrypt); let mut secret_key_armor = None; if needs_signing { let key = if let Some(fingerprint) = identity.and_then(|identity| identity.pgp_fingerprint.as_deref()) { db::pgp::get_key(conn, fingerprint).map_err(AppError::InternalError)? } else { db::pgp::find_key_internal_by_email(conn, sender_email) .map_err(AppError::InternalError)? } .ok_or_else(|| { AppError::BadRequest( "No OpenPGP signing key is configured for this identity".to_string(), ) })?; let path = key.secret_key_path.ok_or_else(|| { AppError::BadRequest("The selected OpenPGP key has no private key material".to_string()) })?; secret_key_armor = Some(crate::pgp::read_secret_key(&path).map_err(AppError::InternalError)?); } let mut recipient_public_keys = Vec::new(); if needs_encryption { let prefs = db::pgp::get_preferences(conn).map_err(AppError::InternalError)?; let mut seen = std::collections::HashSet::new(); for value in to.iter().chain(cc).chain(bcc) { let email = mailbox_address(value)?; let key = db::pgp::find_key_internal_by_email(conn, &email) .map_err(AppError::InternalError)? .ok_or_else(|| AppError::BadRequest(format!("Missing OpenPGP key for {email}")))?; if key.record.trust == "untrusted" { return Err(AppError::BadRequest(format!( "OpenPGP key for {email} is explicitly untrusted" ))); } if prefs.require_trusted_encryption && !matches!(key.record.trust.as_str(), "trusted" | "verified") { return Err(AppError::BadRequest(format!( "OpenPGP key for {email} is not trusted" ))); } if seen.insert(key.record.fingerprint.clone()) { recipient_public_keys.push(key.record.public_key_armor); } } if let Some(key) = db::pgp::find_key_internal_by_email(conn, sender_email) .map_err(AppError::InternalError)? && seen.insert(key.record.fingerprint.clone()) { recipient_public_keys.push(key.record.public_key_armor); } } let _ = (data_dir, user_hash); Ok(Some(PgpSendConfig { mode, inline, secret_key_armor, passphrase: passphrase.to_string(), recipient_public_keys, })) } fn mailbox_address(value: &str) -> Result { let mailbox: lettre::message::Mailbox = value .parse() .map_err(|_| AppError::BadRequest(format!("Invalid email address: {value}")))?; Ok(mailbox.email.to_string().to_ascii_lowercase()) }