feat: implement real email sending via lettre + Vault credentials
Some checks failed
BotServer CI/CD / build (push) Failing after 8m6s
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:
parent
0de4565e5a
commit
45eb8357cb
6 changed files with 212 additions and 13 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ async fn execute_send_mail(
|
|||
to,
|
||||
subject,
|
||||
body,
|
||||
user.bot_id,
|
||||
if attachments.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
bot_id: uuid::Uuid,
|
||||
email: &str,
|
||||
message: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
|
@ -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<AppState>,
|
||||
bot_id: uuid::Uuid,
|
||||
email: &str,
|
||||
file_data: Vec<u8>,
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>>) -> 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<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());
|
||||
}
|
||||
|
||||
pub fn send_email_with_attachment(&self, to: &str, _subject: &str, _body: &str, _file_data: Vec<u8>, _filename: &str) -> Result<(), String> {
|
||||
log::warn!("EmailService::send_email_with_attachment not fully implemented. to: {}", to);
|
||||
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,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue