diff --git a/src/basic/keywords/hearing/talk.rs b/src/basic/keywords/hearing/talk.rs index 1bab5c00..887061cc 100644 --- a/src/basic/keywords/hearing/talk.rs +++ b/src/basic/keywords/hearing/talk.rs @@ -57,7 +57,6 @@ pub async fn execute_talk( let mut wa_response = response_clone; wa_response.user_id = target_user_id; - let pool = state.conn.clone(); let bot_id = user_session.bot_id; tokio::spawn(async move { diff --git a/src/basic/keywords/import_export.rs b/src/basic/keywords/import_export.rs index bbddb62c..061aa79f 100644 --- a/src/basic/keywords/import_export.rs +++ b/src/basic/keywords/import_export.rs @@ -234,7 +234,6 @@ fn resolve_file_path( return Ok(file_path.to_string()); } - let work = get_work_path(); let work = get_work_path(); let data_dir = state .config diff --git a/src/basic/keywords/send_mail.rs b/src/basic/keywords/send_mail.rs index c45fc80c..b9424f2f 100644 --- a/src/basic/keywords/send_mail.rs +++ b/src/basic/keywords/send_mail.rs @@ -353,6 +353,7 @@ async fn execute_send_mail( to, subject, body, + user.bot_id, if attachments.is_empty() { None } else { diff --git a/src/basic/keywords/universal_messaging.rs b/src/basic/keywords/universal_messaging.rs index aaa89b14..e00dcbba 100644 --- a/src/basic/keywords/universal_messaging.rs +++ b/src/basic/keywords/universal_messaging.rs @@ -296,7 +296,7 @@ pub async fn send_message_to_recipient( send_web_message(state.clone(), &recipient_id, message).await?; } "email" => { - send_email(state.clone(), &recipient_id, message)?; + send_email(state.clone(), user.bot_id, &recipient_id, message)?; } _ => { error!("Unknown channel: {}", channel); @@ -346,7 +346,7 @@ async fn send_file_with_caption_to_recipient( send_web_file(state, &recipient_id, file_data, caption).await?; } "email" => { - send_email_attachment(state, &recipient_id, file_data, caption)?; + send_email_attachment(state, user.bot_id, &recipient_id, file_data, caption)?; } _ => { return Err(format!("Unsupported channel for file sending: {}", channel).into()); @@ -663,6 +663,7 @@ async fn send_web_file( fn send_email( state: Arc, + bot_id: uuid::Uuid, email: &str, message: &str, ) -> Result<(), Box> { @@ -671,13 +672,13 @@ fn send_email( use crate::email::EmailService; let email_service = EmailService::new(state); - email_service.send_email(email, "Message from Bot", message, None)?; + email_service.send_email(email, "Message from Bot", message, bot_id, None)?; Ok(()) } #[cfg(not(feature = "mail"))] { - let _ = (state, email, message); + let _ = (state, bot_id, email, message); error!("Email feature not enabled"); Err("Email feature not enabled".into()) } @@ -685,6 +686,7 @@ fn send_email( fn send_email_attachment( state: Arc, + bot_id: uuid::Uuid, email: &str, file_data: Vec, caption: &str, @@ -698,6 +700,7 @@ fn send_email_attachment( email, "File from Bot", caption, + bot_id, file_data, "attachment", )?; @@ -706,7 +709,7 @@ fn send_email_attachment( #[cfg(not(feature = "mail"))] { - let _ = (state, email, file_data, caption); + let _ = (state, bot_id, email, file_data, caption); error!("Email feature not enabled for attachments"); Err("Email feature not enabled".into()) } diff --git a/src/core/secrets/mod.rs b/src/core/secrets/mod.rs index 052ce135..ca66da3a 100644 --- a/src/core/secrets/mod.rs +++ b/src/core/secrets/mod.rs @@ -859,6 +859,50 @@ impl SecretsManager { Ok(self.get_secret(&format!("{}/oauth/{}", path, provider)).await.ok()) } + // ============ BOT EMAIL RESOLUTION (bot → default bot → system) ============ + + /// Get email config for a specific bot with inheritance chain: + /// 1. Bot-specific: `gbo/bots/{bot_id}/email` + /// 2. Default bot: `gbo/bots/default/email` + /// 3. System-wide: `gbo/email` + pub fn get_email_config_for_bot_sync(&self, bot_id: &Uuid) -> (String, u16, String, String, String) { + let bot_path = format!("gbo/bots/{}/email", bot_id); + let default_path = "gbo/bots/default/email".to_string(); + let self_owned = self.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let result = if let Ok(rt) = rt { + rt.block_on(async move { + if let Ok(s) = self_owned.get_secret(&bot_path).await { + return Some(s); + } + if let Ok(s) = self_owned.get_secret(&default_path).await { + return Some(s); + } + self_owned.get_secret(SecretPaths::EMAIL).await.ok() + }) + } else { + None + }; + let _ = tx.send(result); + }); + + if let Ok(Some(secrets)) = rx.recv() { + return ( + secrets.get("smtp_host").cloned().unwrap_or_default(), + secrets.get("smtp_port").and_then(|p| p.parse().ok()).unwrap_or(587), + secrets.get("smtp_user").cloned().unwrap_or_default(), + secrets.get("smtp_password").cloned().unwrap_or_default(), + secrets.get("smtp_from").cloned().unwrap_or_default(), + ); + } + (String::new(), 587, String::new(), String::new(), String::new()) + } + // ============ TENANT-AWARE METHODS (org_id -> tenant -> secrets) ============ /// Get database config for an organization (resolves tenant from org, then gets infra) diff --git a/src/email/types.rs b/src/email/types.rs index 9faa689f..0fe45e56 100644 --- a/src/email/types.rs +++ b/src/email/types.rs @@ -1,4 +1,7 @@ -use axum::{http::StatusCode, response::{IntoResponse, Response}}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; use chrono::{DateTime, Utc}; use diesel::prelude::*; use diesel::sql_types::{Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid, Varchar}; @@ -305,13 +308,163 @@ impl EmailService { Self { state } } - pub fn send_email(&self, to: &str, _subject: &str, _body: &str, _attachments: Option>) -> Result<(), String> { - log::warn!("EmailService::send_email not fully implemented. to: {}", to); - Ok(()) + pub fn send_email( + &self, + to: &str, + subject: &str, + body: &str, + bot_id: Uuid, + _attachments: Option>, + ) -> Result { + use lettre::message::{header::ContentType, Message}; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{SmtpTransport, Transport}; + + let secrets = crate::core::secrets::SecretsManager::from_env() + .map_err(|e| format!("Vault not available: {}", e))?; + let (smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from): ( + String, + u16, + String, + String, + String, + ) = secrets.get_email_config_for_bot_sync(&bot_id); + + if smtp_from.is_empty() { + log::warn!( + "No SMTP from address configured in Vault for bot {}", + bot_id + ); + return Err("SMTP not configured: set email credentials in Vault".into()); + } + + let email = Message::builder() + .from( + smtp_from + .parse() + .map_err(|e| format!("Invalid from address: {}", e))?, + ) + .to(to + .parse() + .map_err(|e| format!("Invalid to address: {}", e))?) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(body.to_string()) + .map_err(|e| format!("Failed to build email: {}", e))?; + + let mailer = if !smtp_user.is_empty() && !smtp_pass.is_empty() { + let creds = Credentials::new(smtp_user, smtp_pass); + SmtpTransport::relay(&smtp_host) + .map_err(|e| format!("SMTP relay error: {}", e))? + .port(smtp_port) + .credentials(creds) + .build() + } else { + SmtpTransport::relay(&smtp_host) + .map_err(|e| format!("SMTP relay error: {}", e))? + .port(smtp_port) + .build() + }; + + mailer + .send(&email) + .map_err(|e| format!("Failed to send email: {}", e))?; + + info!("Email sent to {} via {} (bot {})", to, smtp_host, bot_id); + Ok(format!("sent-{}", bot_id)) } - pub fn send_email_with_attachment(&self, to: &str, _subject: &str, _body: &str, _file_data: Vec, _filename: &str) -> Result<(), String> { - log::warn!("EmailService::send_email_with_attachment not fully implemented. to: {}", to); + pub fn send_email_with_attachment( + &self, + to: &str, + subject: &str, + body: &str, + bot_id: Uuid, + file_data: Vec, + filename: &str, + ) -> Result<(), String> { + use lettre::message::{ + header::ContentType, Attachment, Body, Message, MultiPart, SinglePart, + }; + use lettre::transport::smtp::authentication::Credentials; + use lettre::{SmtpTransport, Transport}; + + let secrets = crate::core::secrets::SecretsManager::from_env() + .map_err(|e| format!("Vault not available: {}", e))?; + let (smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from): ( + String, + u16, + String, + String, + String, + ) = secrets.get_email_config_for_bot_sync(&bot_id); + + if smtp_from.is_empty() { + return Err("SMTP not configured: set email credentials in Vault".into()); + } + + let mime_type: mime::Mime = filename + .split('.') + .last() + .and_then(|ext| match ext { + "pdf" => Some("application/pdf".parse().ok()), + "png" => Some("image/png".parse().ok()), + "jpg" | "jpeg" => Some("image/jpeg".parse().ok()), + "txt" => Some("text/plain".parse().ok()), + "csv" => Some("text/csv".parse().ok()), + "xlsx" => Some( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + .parse() + .ok(), + ), + "docx" => Some( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + .parse() + .ok(), + ), + _ => Some("application/octet-stream".parse().ok()), + }) + .flatten() + .unwrap_or_else(|| "application/octet-stream".parse().unwrap()); + + let email = Message::builder() + .from( + smtp_from + .parse() + .map_err(|e| format!("Invalid from address: {}", e))?, + ) + .to(to + .parse() + .map_err(|e| format!("Invalid to address: {}", e))?) + .subject(subject) + .multipart( + MultiPart::mixed() + .singlepart(SinglePart::html(body.to_string())) + .singlepart( + Attachment::new(filename.to_string()).body(Body::new(file_data), mime_type), + ), + ) + .map_err(|e| format!("Failed to build email: {}", e))?; + + let mailer = if !smtp_user.is_empty() && !smtp_pass.is_empty() { + let creds = Credentials::new(smtp_user, smtp_pass); + SmtpTransport::relay(&smtp_host) + .map_err(|e| format!("SMTP relay error: {}", e))? + .port(smtp_port) + .credentials(creds) + .build() + } else { + SmtpTransport::relay(&smtp_host) + .map_err(|e| format!("SMTP relay error: {}", e))? + .port(smtp_port) + .build() + }; + + mailer + .send(&email) + .map_err(|e| format!("Failed to send email: {}", e))?; + + info!("Email with attachment sent to {} (bot {})", to, bot_id); Ok(()) } }