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;
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue