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; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmailCampaignPayload { pub to: String, pub subject: String, pub body_html: Option, pub body_text: Option, pub campaign_id: Option, pub recipient_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmailSendResult { pub success: bool, pub message_id: Option, pub tracking_id: Option, pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmailTrackingRecord { pub id: Uuid, pub recipient_id: Option, pub campaign_id: Option, pub message_id: Option, pub open_token: Option, pub opened: bool, pub opened_at: Option>, pub clicked: bool, pub clicked_at: Option>, pub ip_address: Option, } #[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> { 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); if smtp_from.is_empty() { return Err("SMTP not configured: set email credentials in Vault".into()); } Ok((smtp_host, smtp_port, smtp_from, smtp_user, smtp_pass)) } 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#""#, pixel_url ); if html.to_lowercase().contains("") { html.replace("", &format!("{}", pixel)) .replace("", &format!("{}", 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, bot_id: Uuid, payload: EmailCampaignPayload, ) -> Result { 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))?; let body = body_html.unwrap_or_else(|| payload.body_text.unwrap_or_default()); let email_service = EmailService::new(state.clone()); match email_service.send_email(&payload.to, &payload.subject, &body, bot_id, None) { Ok(message_id) => { diesel::update(email_tracking::table.filter(email_tracking::id.eq(tracking_id))) .set(email_tracking::message_id.eq(Some(message_id.clone()))) .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, message_id: Some(message_id), tracking_id: Some(tracking_id), error: None, }) } Err(e) => { 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("failed"), marketing_recipients::failed_at.eq(Some(Utc::now())), marketing_recipients::error_message.eq(Some(e.clone())), )) .execute(&mut conn) .ok(); } Ok(EmailSendResult { success: false, message_id: None, tracking_id: Some(tracking_id), error: Some(e), }) } } } )) .execute(&mut conn) .ok(); } Ok(EmailSendResult { success: false, message_id: None, tracking_id: Some(tracking_id), error: Some(e.to_string()), }) } } } pub async fn get_campaign_email_metrics( state: &Arc, campaign_id: Uuid, ) -> Result { let mut conn = state.conn.get().map_err(|e| format!("DB error: {}", e))?; let results: Vec<(Option, Option)> = 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; let opened = results.iter().filter(|(o, _)| o.unwrap_or(false)).count() as i64; let clicked = results.iter().filter(|(_, c)| c.unwrap_or(false)).count() as i64; let recipients: Vec<(String, Option>)> = 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, 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 { let personalized_body = body_html.as_ref().map(|html| { 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, pub body_text: Option, } pub async fn send_email_api( State(state): State>, Json(req): Json, ) -> Result, (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)), } }