feat: implement real email sending via lettre + Vault credentials
Some checks failed
BotServer CI/CD / build (push) Failing after 8m6s

- Replace EmailService::send_email stub with full lettre SMTP implementation
- Vault resolution chain: bot-specific → default bot → system fallback
- Seed Vault prod with default email config (contato@pragmatismo.com.br)
- Update all call sites to pass bot_id for Vault lookup
- Support attachments via lettre MultiPart/Attachment API
- Remove unused imports and dead code
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-04-04 17:16:50 -03:00
parent 0de4565e5a
commit 45eb8357cb
6 changed files with 212 additions and 13 deletions

View file

@ -57,7 +57,6 @@ pub async fn execute_talk(
let mut wa_response = response_clone; let mut wa_response = response_clone;
wa_response.user_id = target_user_id; wa_response.user_id = target_user_id;
let pool = state.conn.clone();
let bot_id = user_session.bot_id; let bot_id = user_session.bot_id;
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -234,7 +234,6 @@ fn resolve_file_path(
return Ok(file_path.to_string()); return Ok(file_path.to_string());
} }
let work = get_work_path();
let work = get_work_path(); let work = get_work_path();
let data_dir = state let data_dir = state
.config .config

View file

@ -353,6 +353,7 @@ async fn execute_send_mail(
to, to,
subject, subject,
body, body,
user.bot_id,
if attachments.is_empty() { if attachments.is_empty() {
None None
} else { } else {

View file

@ -296,7 +296,7 @@ pub async fn send_message_to_recipient(
send_web_message(state.clone(), &recipient_id, message).await?; send_web_message(state.clone(), &recipient_id, message).await?;
} }
"email" => { "email" => {
send_email(state.clone(), &recipient_id, message)?; send_email(state.clone(), user.bot_id, &recipient_id, message)?;
} }
_ => { _ => {
error!("Unknown channel: {}", channel); 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?; send_web_file(state, &recipient_id, file_data, caption).await?;
} }
"email" => { "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()); return Err(format!("Unsupported channel for file sending: {}", channel).into());
@ -663,6 +663,7 @@ async fn send_web_file(
fn send_email( fn send_email(
state: Arc<AppState>, state: Arc<AppState>,
bot_id: uuid::Uuid,
email: &str, email: &str,
message: &str, message: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@ -671,13 +672,13 @@ fn send_email(
use crate::email::EmailService; use crate::email::EmailService;
let email_service = EmailService::new(state); 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(()) Ok(())
} }
#[cfg(not(feature = "mail"))] #[cfg(not(feature = "mail"))]
{ {
let _ = (state, email, message); let _ = (state, bot_id, email, message);
error!("Email feature not enabled"); error!("Email feature not enabled");
Err("Email feature not enabled".into()) Err("Email feature not enabled".into())
} }
@ -685,6 +686,7 @@ fn send_email(
fn send_email_attachment( fn send_email_attachment(
state: Arc<AppState>, state: Arc<AppState>,
bot_id: uuid::Uuid,
email: &str, email: &str,
file_data: Vec<u8>, file_data: Vec<u8>,
caption: &str, caption: &str,
@ -698,6 +700,7 @@ fn send_email_attachment(
email, email,
"File from Bot", "File from Bot",
caption, caption,
bot_id,
file_data, file_data,
"attachment", "attachment",
)?; )?;
@ -706,7 +709,7 @@ fn send_email_attachment(
#[cfg(not(feature = "mail"))] #[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"); error!("Email feature not enabled for attachments");
Err("Email feature not enabled".into()) Err("Email feature not enabled".into())
} }

View file

@ -859,6 +859,50 @@ impl SecretsManager {
Ok(self.get_secret(&format!("{}/oauth/{}", path, provider)).await.ok()) 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) ============ // ============ TENANT-AWARE METHODS (org_id -> tenant -> secrets) ============
/// Get database config for an organization (resolves tenant from org, then gets infra) /// Get database config for an organization (resolves tenant from org, then gets infra)

View file

@ -1,4 +1,7 @@
use axum::{http::StatusCode, response::{IntoResponse, Response}}; use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_types::{Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid, Varchar}; use diesel::sql_types::{Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid, Varchar};
@ -305,13 +308,163 @@ impl EmailService {
Self { state } Self { state }
} }
pub fn send_email(&self, to: &str, _subject: &str, _body: &str, _attachments: Option<Vec<String>>) -> Result<(), String> { pub fn send_email(
log::warn!("EmailService::send_email not fully implemented. to: {}", to); &self,
Ok(()) to: &str,
subject: &str,
body: &str,
bot_id: Uuid,
_attachments: Option<Vec<String>>,
) -> Result<String, String> {
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<u8>, _filename: &str) -> Result<(), String> { pub fn send_email_with_attachment(
log::warn!("EmailService::send_email_with_attachment not fully implemented. to: {}", to); &self,
to: &str,
subject: &str,
body: &str,
bot_id: Uuid,
file_data: Vec<u8>,
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(()) Ok(())
} }
} }