2026-03-15 15:50:02 -03:00
|
|
|
use axum::{
|
|
|
|
|
extract::State,
|
|
|
|
|
http::StatusCode,
|
|
|
|
|
Json,
|
|
|
|
|
};
|
|
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use diesel::prelude::*;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
2026-04-04 22:27:08 -03:00
|
|
|
use crate::core::shared::state::AppState;
|
|
|
|
|
|
2026-03-15 15:50:02 -03:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct EmailCampaignPayload {
|
|
|
|
|
pub to: String,
|
|
|
|
|
pub subject: String,
|
|
|
|
|
pub body_html: Option<String>,
|
|
|
|
|
pub body_text: Option<String>,
|
|
|
|
|
pub campaign_id: Option<Uuid>,
|
|
|
|
|
pub recipient_id: Option<Uuid>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct EmailSendResult {
|
|
|
|
|
pub success: bool,
|
|
|
|
|
pub message_id: Option<String>,
|
|
|
|
|
pub tracking_id: Option<Uuid>,
|
|
|
|
|
pub error: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct EmailTrackingRecord {
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
pub recipient_id: Option<Uuid>,
|
|
|
|
|
pub campaign_id: Option<Uuid>,
|
|
|
|
|
pub message_id: Option<String>,
|
|
|
|
|
pub open_token: Option<Uuid>,
|
|
|
|
|
pub opened: bool,
|
|
|
|
|
pub opened_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub clicked: bool,
|
|
|
|
|
pub clicked_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub ip_address: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct CampaignMetrics {
|
|
|
|
|
pub total_sent: i64,
|
|
|
|
|
pub total_delivered: i64,
|
|
|
|
|
pub total_failed: i64,
|
|
|
|
|
pub total_opened: i64,
|
|
|
|
|
pub total_clicked: i64,
|
|
|
|
|
pub open_rate: f64,
|
|
|
|
|
pub click_rate: f64,
|
|
|
|
|
pub bounce_rate: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_smtp_config(state: &AppState, bot_id: Uuid) -> Result<(String, u16, String, String, String), String> {
|
2026-04-04 22:16:20 -03:00
|
|
|
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) =
|
|
|
|
|
secrets.get_email_config_for_bot_sync(&bot_id);
|
2026-03-15 15:50:02 -03:00
|
|
|
|
2026-04-04 22:16:20 -03:00
|
|
|
if smtp_from.is_empty() {
|
|
|
|
|
return Err("SMTP not configured: set email credentials in Vault".into());
|
|
|
|
|
}
|
2026-03-15 15:50:02 -03:00
|
|
|
|
2026-04-04 22:16:20 -03:00
|
|
|
Ok((smtp_host, smtp_port, smtp_from, smtp_user, smtp_pass))
|
2026-03-15 15:50:02 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inject_tracking_pixel(html: &str, token: Uuid, base_url: &str) -> String {
|
|
|
|
|
let pixel_url = format!("{}/api/marketing/track/open/{}", base_url, token);
|
|
|
|
|
let pixel = format!(
|
|
|
|
|
r#"<img src="{}" width="1" height="1" alt="" style="display:none;visibility:hidden;border:0;" />"#,
|
|
|
|
|
pixel_url
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if html.to_lowercase().contains("</body>") {
|
|
|
|
|
html.replace("</body>", &format!("{}</body>", pixel))
|
|
|
|
|
.replace("</BODY>", &format!("{}</BODY>", pixel))
|
|
|
|
|
} else {
|
|
|
|
|
format!("{}{}", html, pixel)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wrap_tracking_links(html: &str, tracking_id: Uuid, base_url: &str) -> String {
|
|
|
|
|
let wrapped = html.replace(
|
|
|
|
|
"href=\"",
|
|
|
|
|
&format!("href=\"{}/api/marketing/track/click/{}/", base_url, tracking_id),
|
|
|
|
|
);
|
|
|
|
|
wrapped.replace(
|
|
|
|
|
"href='",
|
|
|
|
|
&format!("href='{}/api/marketing/track/click/{}/", base_url, tracking_id),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn send_campaign_email(
|
|
|
|
|
state: &Arc<AppState>,
|
|
|
|
|
bot_id: Uuid,
|
|
|
|
|
payload: EmailCampaignPayload,
|
|
|
|
|
) -> Result<EmailSendResult, String> {
|
|
|
|
|
let open_token = Uuid::new_v4();
|
|
|
|
|
let tracking_id = Uuid::new_v4();
|
|
|
|
|
|
|
|
|
|
let config = ConfigManager::new(state.conn.clone());
|
|
|
|
|
let base_url = config
|
|
|
|
|
.get_config(&bot_id, "server-url", Some("http://localhost:3000"))
|
|
|
|
|
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
|
|
|
|
|
|
|
|
|
let body_html = payload
|
|
|
|
|
.body_html
|
|
|
|
|
.map(|html| wrap_tracking_links(&html, tracking_id, &base_url))
|
|
|
|
|
.map(|html| inject_tracking_pixel(&html, open_token, &base_url));
|
|
|
|
|
|
|
|
|
|
let mut conn = state.conn.get().map_err(|e| format!("DB connection failed: {}", e))?;
|
|
|
|
|
|
|
|
|
|
let tracking_record = EmailTrackingRecord {
|
|
|
|
|
id: tracking_id,
|
|
|
|
|
recipient_id: payload.recipient_id,
|
|
|
|
|
campaign_id: payload.campaign_id,
|
|
|
|
|
message_id: None,
|
|
|
|
|
open_token: Some(open_token),
|
|
|
|
|
opened: false,
|
|
|
|
|
opened_at: None,
|
|
|
|
|
clicked: false,
|
|
|
|
|
clicked_at: None,
|
|
|
|
|
ip_address: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
diesel::insert_into(email_tracking::table)
|
|
|
|
|
.values((
|
|
|
|
|
email_tracking::id.eq(tracking_record.id),
|
|
|
|
|
email_tracking::recipient_id.eq(tracking_record.recipient_id),
|
|
|
|
|
email_tracking::campaign_id.eq(tracking_record.campaign_id),
|
|
|
|
|
email_tracking::open_token.eq(tracking_record.open_token),
|
|
|
|
|
email_tracking::open_tracking_enabled.eq(true),
|
|
|
|
|
email_tracking::opened.eq(false),
|
|
|
|
|
email_tracking::clicked.eq(false),
|
|
|
|
|
email_tracking::created_at.eq(Utc::now()),
|
|
|
|
|
))
|
|
|
|
|
.execute(&mut conn)
|
|
|
|
|
.map_err(|e| format!("Failed to create tracking record: {}", e))?;
|
|
|
|
|
|
2026-04-04 22:16:20 -03:00
|
|
|
let body = body_html.unwrap_or_else(|| payload.body_text.unwrap_or_default());
|
2026-03-15 15:50:02 -03:00
|
|
|
|
2026-04-04 22:16:20 -03:00
|
|
|
let email_service = EmailService::new(state.clone());
|
|
|
|
|
match email_service.send_email(&payload.to, &payload.subject, &body, bot_id, None) {
|
2026-04-04 22:39:08 -03:00
|
|
|
Ok(msg_id) => {
|
|
|
|
|
let msg_id_str: String = msg_id;
|
2026-03-15 15:50:02 -03:00
|
|
|
diesel::update(email_tracking::table.filter(email_tracking::id.eq(tracking_id)))
|
2026-04-04 22:39:08 -03:00
|
|
|
.set(email_tracking::message_id.eq(Some(msg_id_str)))
|
2026-03-15 15:50:02 -03:00
|
|
|
.execute(&mut conn)
|
|
|
|
|
.ok();
|
|
|
|
|
|
|
|
|
|
if let Some(recipient_id) = payload.recipient_id {
|
|
|
|
|
diesel::update(marketing_recipients::table.filter(marketing_recipients::id.eq(recipient_id)))
|
|
|
|
|
.set((
|
|
|
|
|
marketing_recipients::status.eq("sent"),
|
|
|
|
|
marketing_recipients::sent_at.eq(Some(Utc::now())),
|
|
|
|
|
))
|
|
|
|
|
.execute(&mut conn)
|
|
|
|
|
.ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(EmailSendResult {
|
|
|
|
|
success: true,
|
2026-04-04 22:39:08 -03:00
|
|
|
message_id: Some(msg_id),
|
2026-03-15 15:50:02 -03:00
|
|
|
tracking_id: Some(tracking_id),
|
|
|
|
|
error: None,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if let Some(recipient_id) = payload.recipient_id {
|
2026-04-04 22:39:08 -03:00
|
|
|
let err_msg: String = e.clone();
|
2026-03-15 15:50:02 -03:00
|
|
|
diesel::update(marketing_recipients::table.filter(marketing_recipients::id.eq(recipient_id)))
|
|
|
|
|
.set((
|
|
|
|
|
marketing_recipients::status.eq("failed"),
|
|
|
|
|
marketing_recipients::failed_at.eq(Some(Utc::now())),
|
2026-04-04 22:39:08 -03:00
|
|
|
marketing_recipients::error_message.eq(Some(err_msg)),
|
2026-04-04 22:16:20 -03:00
|
|
|
))
|
|
|
|
|
.execute(&mut conn)
|
|
|
|
|
.ok();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(EmailSendResult {
|
|
|
|
|
success: false,
|
|
|
|
|
message_id: None,
|
|
|
|
|
tracking_id: Some(tracking_id),
|
|
|
|
|
error: Some(e),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-15 15:50:02 -03:00
|
|
|
|
|
|
|
|
pub async fn get_campaign_email_metrics(
|
|
|
|
|
state: &Arc<AppState>,
|
|
|
|
|
campaign_id: Uuid,
|
|
|
|
|
) -> Result<CampaignMetrics, String> {
|
|
|
|
|
let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?;
|
|
|
|
|
|
|
|
|
|
let results: Vec<(Option<bool>, Option<bool>)> = email_tracking::table
|
|
|
|
|
.filter(email_tracking::campaign_id.eq(campaign_id))
|
|
|
|
|
.select((email_tracking::opened, email_tracking::clicked))
|
|
|
|
|
.load(&mut conn)
|
|
|
|
|
.map_err(|e| format!("Query error: {}", e))?;
|
|
|
|
|
|
|
|
|
|
let total = results.len() as i64;
|
2026-04-04 22:39:08 -03:00
|
|
|
let opened = results.iter().filter(|(o, _): &(Option<bool>, Option<bool>)| o.unwrap_or(false)).count() as i64;
|
|
|
|
|
let clicked = results.iter().filter(|(_, c): &(Option<bool>, Option<bool>)| c.unwrap_or(false)).count() as i64;
|
2026-03-15 15:50:02 -03:00
|
|
|
|
|
|
|
|
let recipients: Vec<(String, Option<DateTime<Utc>>)> = marketing_recipients::table
|
|
|
|
|
.filter(marketing_recipients::campaign_id.eq(campaign_id))
|
|
|
|
|
.filter(marketing_recipients::channel.eq("email"))
|
|
|
|
|
.select((marketing_recipients::status, marketing_recipients::sent_at))
|
|
|
|
|
.load(&mut conn)
|
|
|
|
|
.map_err(|e| format!("Query error: {}", e))?;
|
|
|
|
|
|
|
|
|
|
let sent = recipients.iter().filter(|(s, _)| s == "sent").count() as i64;
|
|
|
|
|
let failed = recipients.iter().filter(|(s, _)| s == "failed").count() as i64;
|
|
|
|
|
let delivered = sent;
|
|
|
|
|
|
|
|
|
|
Ok(CampaignMetrics {
|
|
|
|
|
total_sent: total,
|
|
|
|
|
total_delivered: delivered,
|
|
|
|
|
total_failed: failed,
|
|
|
|
|
total_opened: opened,
|
|
|
|
|
total_clicked: clicked,
|
|
|
|
|
open_rate: if delivered > 0 { (opened as f64 / delivered as f64) * 100.0 } else { 0.0 },
|
|
|
|
|
click_rate: if delivered > 0 { (clicked as f64 / delivered as f64) * 100.0 } else { 0.0 },
|
|
|
|
|
bounce_rate: if sent > 0 { (failed as f64 / sent as f64) * 100.0 } else { 0.0 },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn send_bulk_campaign_emails(
|
|
|
|
|
state: &Arc<AppState>,
|
|
|
|
|
campaign_id: Uuid,
|
|
|
|
|
contacts: Vec<(Uuid, String, String)>,
|
|
|
|
|
) -> Result<(i32, i32), String> {
|
|
|
|
|
let mut sent = 0;
|
|
|
|
|
let mut failed = 0;
|
|
|
|
|
|
|
|
|
|
let campaign: CrmCampaign = marketing_campaigns::table
|
|
|
|
|
.filter(marketing_campaigns::id.eq(campaign_id))
|
|
|
|
|
.first(&mut *state.conn.get().map_err(|e| format!("DB error: {}", e))?)
|
|
|
|
|
.map_err(|_| "Campaign not found")?;
|
|
|
|
|
|
|
|
|
|
let subject = campaign
|
|
|
|
|
.content_template
|
|
|
|
|
.get("subject")
|
|
|
|
|
.and_then(|s| s.as_str())
|
|
|
|
|
.unwrap_or("Newsletter")
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let body_html = campaign
|
|
|
|
|
.content_template
|
|
|
|
|
.get("body")
|
|
|
|
|
.and_then(|b| b.as_str())
|
|
|
|
|
.map(String::from);
|
|
|
|
|
|
|
|
|
|
for (contact_id, email, name) in contacts {
|
2026-04-04 22:27:08 -03:00
|
|
|
let personalized_body = body_html.as_ref().map(|html: &String| {
|
2026-03-15 15:50:02 -03:00
|
|
|
html.replace("{{name}}", &name)
|
|
|
|
|
.replace("{{email}}", &email)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let payload = EmailCampaignPayload {
|
|
|
|
|
to: email,
|
|
|
|
|
subject: subject.clone(),
|
|
|
|
|
body_html: personalized_body.clone(),
|
|
|
|
|
body_text: None,
|
|
|
|
|
campaign_id: Some(campaign_id),
|
|
|
|
|
recipient_id: Some(contact_id),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match send_campaign_email(state, campaign.bot_id, payload).await {
|
|
|
|
|
Ok(result) => {
|
|
|
|
|
if result.success {
|
|
|
|
|
sent += 1;
|
|
|
|
|
} else {
|
|
|
|
|
failed += 1;
|
|
|
|
|
log::error!("Email send failed: {:?}", result.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
failed += 1;
|
|
|
|
|
log::error!("Email error: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok((sent, failed))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct SendEmailRequest {
|
|
|
|
|
pub to: String,
|
|
|
|
|
pub subject: String,
|
|
|
|
|
pub body_html: Option<String>,
|
|
|
|
|
pub body_text: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn send_email_api(
|
|
|
|
|
State(state): State<Arc<AppState>>,
|
|
|
|
|
Json(req): Json<SendEmailRequest>,
|
|
|
|
|
) -> Result<Json<EmailSendResult>, (StatusCode, String)> {
|
|
|
|
|
let bot_id = Uuid::nil();
|
|
|
|
|
|
|
|
|
|
let payload = EmailCampaignPayload {
|
|
|
|
|
to: req.to,
|
|
|
|
|
subject: req.subject,
|
|
|
|
|
body_html: req.body_html,
|
|
|
|
|
body_text: req.body_text,
|
|
|
|
|
campaign_id: None,
|
|
|
|
|
recipient_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match send_campaign_email(&state, bot_id, payload).await {
|
|
|
|
|
Ok(result) => Ok(Json(result)),
|
|
|
|
|
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
|
|
|
|
|
}
|
|
|
|
|
}
|