pub mod ui;
use crate::{config::EmailConfig, core::urls::ApiUrls, shared::state::AppState};
use crate::core::middleware::AuthenticatedUser;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use axum::{
routing::{delete, get, post},
Router,
};
use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel::sql_types::{Bool, Integer, Nullable, Text, Timestamptz, Uuid as DieselUuid, Varchar};
use imap::types::Seq;
use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport};
use log::{debug, info, warn};
use mailparse::{parse_mail, MailHeaderMap};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, QueryableByName)]
pub struct EmailAccountBasicRow {
#[diesel(sql_type = DieselUuid)]
pub id: Uuid,
#[diesel(sql_type = Text)]
pub email: String,
#[diesel(sql_type = Nullable)]
pub display_name: Option,
#[diesel(sql_type = Bool)]
pub is_primary: bool,
}
#[derive(Debug, QueryableByName)]
pub struct ImapCredentialsRow {
#[diesel(sql_type = Text)]
pub imap_server: String,
#[diesel(sql_type = Integer)]
pub imap_port: i32,
#[diesel(sql_type = Text)]
pub username: String,
#[diesel(sql_type = Text)]
pub password_encrypted: String,
}
#[derive(Debug, QueryableByName)]
pub struct SmtpCredentialsRow {
#[diesel(sql_type = Text)]
pub email: String,
#[diesel(sql_type = Text)]
pub display_name: String,
#[diesel(sql_type = Integer)]
pub smtp_port: i32,
#[diesel(sql_type = Text)]
pub smtp_server: String,
#[diesel(sql_type = Text)]
pub username: String,
#[diesel(sql_type = Text)]
pub password_encrypted: String,
}
#[derive(Debug, QueryableByName)]
pub struct EmailSearchRow {
#[diesel(sql_type = Text)]
pub id: String,
#[diesel(sql_type = Text)]
pub subject: String,
#[diesel(sql_type = Text)]
pub from_address: String,
#[diesel(sql_type = Text)]
pub to_addresses: String,
#[diesel(sql_type = Nullable)]
pub body_text: Option,
#[diesel(sql_type = Timestamptz)]
pub received_at: DateTime,
}
/// Strip HTML tags from a string to create plain text version
fn strip_html_tags(html: &str) -> String {
// Replace common HTML entities
let text = html
.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'");
// Replace
and
with newlines
let text = text
.replace("
", "\n")
.replace("
", "\n")
.replace("
", "\n")
.replace("", "\n")
.replace("", "\n")
.replace("", "\n");
// Remove all remaining HTML tags
let mut result = String::with_capacity(text.len());
let mut in_tag = false;
for c in text.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
// Clean up multiple consecutive newlines and trim
let mut cleaned = String::new();
let mut prev_newline = false;
for c in result.chars() {
if c == '\n' {
if !prev_newline {
cleaned.push(c);
}
prev_newline = true;
} else {
cleaned.push(c);
prev_newline = false;
}
}
cleaned.trim().to_string()
}
#[derive(Debug, QueryableByName, Serialize)]
pub struct EmailSignatureRow {
#[diesel(sql_type = DieselUuid)]
pub id: Uuid,
#[diesel(sql_type = DieselUuid)]
pub user_id: Uuid,
#[diesel(sql_type = Nullable)]
pub bot_id: Option,
#[diesel(sql_type = Varchar)]
pub name: String,
#[diesel(sql_type = Text)]
pub content_html: String,
#[diesel(sql_type = Text)]
pub content_plain: String,
#[diesel(sql_type = Bool)]
pub is_default: bool,
#[diesel(sql_type = Bool)]
pub is_active: bool,
#[diesel(sql_type = Timestamptz)]
pub created_at: DateTime,
#[diesel(sql_type = Timestamptz)]
pub updated_at: DateTime,
}
#[derive(Debug, Deserialize)]
pub struct CreateSignatureRequest {
pub name: String,
pub content_html: String,
#[serde(default)]
pub content_plain: Option,
#[serde(default)]
pub is_default: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSignatureRequest {
pub name: Option,
pub content_html: Option,
pub content_plain: Option,
pub is_default: Option,
pub is_active: Option,
}
pub mod stalwart_client;
pub mod stalwart_sync;
pub mod vectordb;
fn extract_user_from_session(_state: &Arc) -> Result {
Ok(Uuid::new_v4())
}
pub fn configure() -> Router> {
Router::new()
.route(ApiUrls::EMAIL_ACCOUNTS, get(list_email_accounts))
.route(
&format!("{}/add", ApiUrls::EMAIL_ACCOUNTS),
post(add_email_account),
)
.route(
&ApiUrls::EMAIL_ACCOUNT_BY_ID.replace(":id", "{account_id}"),
axum::routing::delete(delete_email_account),
)
.route(ApiUrls::EMAIL_LIST, post(list_emails))
.route(ApiUrls::EMAIL_SEND, post(send_email))
.route(ApiUrls::EMAIL_DRAFT, post(save_draft))
.route(
&ApiUrls::EMAIL_FOLDERS.replace(":account_id", "{account_id}"),
get(list_folders),
)
.route(ApiUrls::EMAIL_LATEST, get(get_latest_email))
.route(
&ApiUrls::EMAIL_GET.replace(":campaign_id", "{campaign_id}"),
get(get_email),
)
.route(
&ApiUrls::EMAIL_CLICK
.replace(":campaign_id", "{campaign_id}")
.replace(":email", "{email}"),
post(track_click),
)
.route(
"/api/email/tracking/pixel/{tracking_id}",
get(serve_tracking_pixel),
)
.route(
"/api/email/tracking/status/{tracking_id}",
get(get_tracking_status),
)
.route("/api/email/tracking/list", get(list_sent_emails_tracking))
.route("/api/email/tracking/stats", get(get_tracking_stats))
// HTMX/HTML APIs
.route(ApiUrls::EMAIL_ACCOUNTS_HTMX, get(list_email_accounts_htmx))
.route(ApiUrls::EMAIL_LIST_HTMX, get(list_emails_htmx))
.route(ApiUrls::EMAIL_FOLDERS_HTMX, get(list_folders_htmx))
.route(ApiUrls::EMAIL_COMPOSE_HTMX, get(compose_email_htmx))
.route(ApiUrls::EMAIL_CONTENT_HTMX, get(get_email_content_htmx))
.route("/api/ui/email/:id/delete", delete(delete_email_htmx))
.route(ApiUrls::EMAIL_LABELS_HTMX, get(list_labels_htmx))
.route(ApiUrls::EMAIL_TEMPLATES_HTMX, get(list_templates_htmx))
.route(ApiUrls::EMAIL_SIGNATURES_HTMX, get(list_signatures_htmx))
.route(ApiUrls::EMAIL_RULES_HTMX, get(list_rules_htmx))
.route(ApiUrls::EMAIL_SEARCH_HTMX, get(search_emails_htmx))
.route(ApiUrls::EMAIL_AUTO_RESPONDER_HTMX, post(save_auto_responder))
// Signatures API
.route("/api/email/signatures", get(list_signatures).post(create_signature))
.route("/api/email/signatures/default", get(get_default_signature))
.route("/api/email/signatures/{id}", get(get_signature).put(update_signature).delete(delete_signature))
}
// =============================================================================
// SIGNATURE HANDLERS
// =============================================================================
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailSignature {
pub id: String,
pub name: String,
pub content_html: String,
pub content_text: String,
pub is_default: bool,
}
pub async fn list_signatures(
State(state): State>,
user: AuthenticatedUser,
) -> impl IntoResponse {
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return Json(serde_json::json!({
"error": format!("Database connection error: {}", e),
"signatures": []
}));
}
};
let user_id = user.user_id;
let result: Result, _> = diesel::sql_query(
"SELECT id, user_id, bot_id, name, content_html, content_plain, is_default, is_active, created_at, updated_at
FROM email_signatures
WHERE user_id = $1 AND is_active = true
ORDER BY is_default DESC, name ASC"
)
.bind::(user_id)
.load(&mut conn);
match result {
Ok(signatures) => Json(serde_json::json!({
"signatures": signatures
})),
Err(e) => {
warn!("Failed to list signatures: {}", e);
// Return empty list with default signature as fallback
Json(serde_json::json!({
"signatures": [{
"id": "default",
"name": "Default Signature",
"content_html": "Best regards,
The Team
",
"content_plain": "Best regards,\nThe Team",
"is_default": true
}]
}))
}
}
}
pub async fn get_default_signature(
State(state): State>,
user: AuthenticatedUser,
) -> impl IntoResponse {
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return Json(serde_json::json!({
"id": "default",
"name": "Default Signature",
"content_html": "Best regards,
The Team
",
"content_plain": "Best regards,\nThe Team",
"is_default": true,
"_error": format!("Database connection error: {}", e)
}));
}
};
let user_id = user.user_id;
let result: Result = diesel::sql_query(
"SELECT id, user_id, bot_id, name, content_html, content_plain, is_default, is_active, created_at, updated_at
FROM email_signatures
WHERE user_id = $1 AND is_default = true AND is_active = true
LIMIT 1"
)
.bind::(user_id)
.get_result(&mut conn);
match result {
Ok(signature) => Json(serde_json::json!({
"id": signature.id,
"name": signature.name,
"content_html": signature.content_html,
"content_plain": signature.content_plain,
"is_default": signature.is_default
})),
Err(_) => {
// Return default signature as fallback
Json(serde_json::json!({
"id": "default",
"name": "Default Signature",
"content_html": "Best regards,
The Team
",
"content_plain": "Best regards,\nThe Team",
"is_default": true
}))
}
}
}
pub async fn get_signature(
State(state): State>,
Path(id): Path,
user: AuthenticatedUser,
) -> impl IntoResponse {
let signature_id = match Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Invalid signature ID"
}))).into_response();
}
};
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Database connection error: {}", e)
}))).into_response();
}
};
let user_id = user.user_id;
let result: Result = diesel::sql_query(
"SELECT id, user_id, bot_id, name, content_html, content_plain, is_default, is_active, created_at, updated_at
FROM email_signatures
WHERE id = $1 AND user_id = $2"
)
.bind::(signature_id)
.bind::(user_id)
.get_result(&mut conn);
match result {
Ok(signature) => Json(serde_json::json!(signature)).into_response(),
Err(_) => (StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "Signature not found"
}))).into_response()
}
}
pub async fn create_signature(
State(state): State>,
user: AuthenticatedUser,
Json(payload): Json,
) -> impl IntoResponse {
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Database connection error: {}", e)
}))).into_response();
}
};
let new_id = Uuid::new_v4();
let user_id = user.user_id;
let content_plain = payload.content_plain.unwrap_or_else(|| {
// Strip HTML tags for plain text version using simple regex
strip_html_tags(&payload.content_html)
});
// If this is set as default, unset other defaults first
if payload.is_default {
let _ = diesel::sql_query(
"UPDATE email_signatures SET is_default = false WHERE user_id = $1 AND is_default = true"
)
.bind::(user_id)
.execute(&mut conn);
}
let result = diesel::sql_query(
"INSERT INTO email_signatures (id, user_id, name, content_html, content_plain, is_default, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, NOW(), NOW())
RETURNING id"
)
.bind::(new_id)
.bind::(user_id)
.bind::(&payload.name)
.bind::(&payload.content_html)
.bind::(&content_plain)
.bind::(payload.is_default)
.execute(&mut conn);
match result {
Ok(_) => Json(serde_json::json!({
"success": true,
"id": new_id,
"name": payload.name
})).into_response(),
Err(e) => {
warn!("Failed to create signature: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Failed to create signature: {}", e)
}))).into_response()
}
}
}
pub async fn update_signature(
State(state): State>,
Path(id): Path,
user: AuthenticatedUser,
Json(payload): Json,
) -> impl IntoResponse {
let signature_id = match Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false,
"error": "Invalid signature ID"
}))).into_response();
}
};
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Database connection error: {}", e)
}))).into_response();
}
};
let user_id = user.user_id;
// Build dynamic update query
let mut updates = vec!["updated_at = NOW()".to_string()];
if payload.name.is_some() {
updates.push("name = $3".to_string());
}
if payload.content_html.is_some() {
updates.push("content_html = $4".to_string());
}
if payload.content_plain.is_some() {
updates.push("content_plain = $5".to_string());
}
if let Some(is_default) = payload.is_default {
if is_default {
// Unset other defaults first
let _ = diesel::sql_query(
"UPDATE email_signatures SET is_default = false WHERE user_id = $1 AND is_default = true AND id != $2"
)
.bind::(user_id)
.bind::(signature_id)
.execute(&mut conn);
}
updates.push("is_default = $6".to_string());
}
if payload.is_active.is_some() {
updates.push("is_active = $7".to_string());
}
let result = diesel::sql_query(format!(
"UPDATE email_signatures SET {} WHERE id = $1 AND user_id = $2",
updates.join(", ")
))
.bind::(signature_id)
.bind::(user_id)
.bind::(payload.name.unwrap_or_default())
.bind::(payload.content_html.unwrap_or_default())
.bind::(payload.content_plain.unwrap_or_default())
.bind::(payload.is_default.unwrap_or(false))
.bind::(payload.is_active.unwrap_or(true))
.execute(&mut conn);
match result {
Ok(rows) if rows > 0 => Json(serde_json::json!({
"success": true,
"id": id
})).into_response(),
Ok(_) => (StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false,
"error": "Signature not found"
}))).into_response(),
Err(e) => {
warn!("Failed to update signature: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Failed to update signature: {}", e)
}))).into_response()
}
}
}
pub async fn delete_signature(
State(state): State>,
Path(id): Path,
user: AuthenticatedUser,
) -> impl IntoResponse {
let signature_id = match Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false,
"error": "Invalid signature ID"
}))).into_response();
}
};
let mut conn = match state.conn.get() {
Ok(c) => c,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Database connection error: {}", e)
}))).into_response();
}
};
let user_id = user.user_id;
// Soft delete by setting is_active = false
let result = diesel::sql_query(
"UPDATE email_signatures SET is_active = false, updated_at = NOW() WHERE id = $1 AND user_id = $2"
)
.bind::(signature_id)
.bind::(user_id)
.execute(&mut conn);
match result {
Ok(rows) if rows > 0 => Json(serde_json::json!({
"success": true,
"id": id
})).into_response(),
Ok(_) => (StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false,
"error": "Signature not found"
}))).into_response(),
Err(e) => {
warn!("Failed to delete signature: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false,
"error": format!("Failed to delete signature: {}", e)
}))).into_response()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveDraftRequest {
pub account_id: String,
pub to: String,
pub cc: Option,
pub bcc: Option,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentEmailTracking {
pub id: String,
pub tracking_id: String,
pub bot_id: String,
pub account_id: String,
pub from_email: String,
pub to_email: String,
pub cc: Option,
pub bcc: Option,
pub subject: String,
pub sent_at: DateTime,
pub read_at: Option>,
pub read_count: i32,
pub first_read_ip: Option,
pub last_read_ip: Option,
pub user_agent: Option,
pub is_read: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackingStatusResponse {
pub tracking_id: String,
pub to_email: String,
pub subject: String,
pub sent_at: String,
pub is_read: bool,
pub read_at: Option,
pub read_count: i32,
}
#[derive(Debug, Deserialize)]
pub struct TrackingPixelQuery {
pub t: Option,
}
#[derive(Debug, Deserialize)]
pub struct ListTrackingQuery {
pub account_id: Option,
pub limit: Option,
pub offset: Option,
pub filter: Option,
}
#[derive(Debug, Serialize)]
pub struct TrackingStatsResponse {
pub total_sent: i64,
pub total_read: i64,
pub read_rate: f64,
pub avg_time_to_read_hours: Option,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailAccountRequest {
pub email: String,
pub display_name: Option,
pub imap_server: String,
pub imap_port: u16,
pub smtp_server: String,
pub smtp_port: u16,
pub username: String,
pub password: String,
pub is_primary: bool,
}
#[derive(Debug, Serialize)]
pub struct EmailAccountResponse {
pub id: String,
pub email: String,
pub display_name: Option,
pub imap_server: String,
pub imap_port: u16,
pub smtp_server: String,
pub smtp_port: u16,
pub is_primary: bool,
pub is_active: bool,
pub created_at: String,
}
#[derive(Debug, Serialize)]
pub struct EmailResponse {
pub id: String,
pub from_name: String,
pub from_email: String,
pub to: String,
pub subject: String,
pub preview: String,
pub body: String,
pub date: String,
pub time: String,
pub read: bool,
pub folder: String,
pub has_attachments: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailRequest {
pub to: String,
pub subject: String,
pub body: String,
pub cc: Option,
pub bcc: Option,
pub attachments: Option>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SendEmailRequest {
pub account_id: String,
pub to: String,
pub cc: Option,
pub bcc: Option,
pub subject: String,
pub body: String,
pub is_html: bool,
}
#[derive(Debug, Serialize)]
pub struct SaveDraftResponse {
pub success: bool,
pub draft_id: Option,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct ListEmailsRequest {
pub account_id: String,
pub folder: Option,
pub limit: Option,
pub offset: Option,
}
#[derive(Debug, Deserialize)]
pub struct MarkEmailRequest {
pub account_id: String,
pub email_id: String,
pub read: bool,
}
#[derive(Debug, Deserialize)]
pub struct DeleteEmailRequest {
pub account_id: String,
pub email_id: String,
}
#[derive(Debug, Serialize)]
pub struct FolderInfo {
pub name: String,
pub path: String,
pub unread_count: i32,
pub total_count: i32,
}
#[derive(Debug, Serialize)]
pub struct ApiResponse {
pub success: bool,
pub data: Option,
pub message: Option,
}
pub struct EmailError(String);
impl IntoResponse for EmailError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.0).into_response()
}
}
impl From for EmailError {
fn from(s: String) -> Self {
Self(s)
}
}
fn parse_from_field(from: &str) -> (String, String) {
if let Some(start) = from.find('<') {
if let Some(end) = from.find('>') {
let name = from[..start].trim().trim_matches('"').to_string();
let email = from[start + 1..end].to_string();
return (name, email);
}
}
(String::new(), from.to_string())
}
fn format_email_time(date_str: &str) -> String {
if date_str.is_empty() {
return "Unknown".to_string();
}
date_str
.split_whitespace()
.take(4)
.collect::>()
.join(" ")
}
fn encrypt_password(password: &str) -> String {
general_purpose::STANDARD.encode(password.as_bytes())
}
fn decrypt_password(encrypted: &str) -> Result {
general_purpose::STANDARD
.decode(encrypted)
.map_err(|e| format!("Decryption failed: {e}"))
.and_then(|bytes| {
String::from_utf8(bytes).map_err(|e| format!("UTF-8 conversion failed: {e}"))
})
}
pub async fn add_email_account(
State(state): State>,
Json(request): Json,
) -> Result>, EmailError> {
let Ok(current_user_id) = extract_user_from_session(&state) else {
return Err(EmailError("Authentication required".to_string()));
};
let account_id = Uuid::new_v4();
let encrypted_password = encrypt_password(&request.password);
let resp_email = request.email.clone();
let resp_display_name = request.display_name.clone();
let resp_imap_server = request.imap_server.clone();
let resp_imap_port = request.imap_port;
let resp_smtp_server = request.smtp_server.clone();
let resp_smtp_port = request.smtp_port;
let resp_is_primary = request.is_primary;
let conn = state.conn.clone();
tokio::task::spawn_blocking(move || {
use crate::shared::models::schema::user_email_accounts::dsl::{is_primary, user_email_accounts, user_id};
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {e}"))?;
if request.is_primary {
diesel::update(user_email_accounts.filter(user_id.eq(¤t_user_id)))
.set(is_primary.eq(false))
.execute(&mut db_conn)
.ok();
}
diesel::sql_query(
"INSERT INTO user_email_accounts
(id, user_id, email, display_name, imap_server, imap_port, smtp_server, smtp_port, username, password_encrypted, is_primary, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
)
.bind::(account_id)
.bind::(current_user_id)
.bind::(&request.email)
.bind::, _>(request.display_name.as_ref())
.bind::(&request.imap_server)
.bind::(i32::from(request.imap_port))
.bind::(&request.smtp_server)
.bind::(i32::from(request.smtp_port))
.bind::(&request.username)
.bind::(&encrypted_password)
.bind::(request.is_primary)
.bind::(true)
.execute(&mut db_conn)
.map_err(|e| format!("Failed to insert account: {e}"))?;
Ok::<_, String>(account_id)
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
Ok(Json(ApiResponse {
success: true,
data: Some(EmailAccountResponse {
id: account_id.to_string(),
email: resp_email,
display_name: resp_display_name,
imap_server: resp_imap_server,
imap_port: resp_imap_port,
smtp_server: resp_smtp_server,
smtp_port: resp_smtp_port,
is_primary: resp_is_primary,
is_active: true,
created_at: chrono::Utc::now().to_rfc3339(),
}),
message: Some("Email account added successfully".to_string()),
}))
}
pub async fn list_email_accounts_htmx(State(state): State>) -> impl IntoResponse {
let Ok(user_id) = extract_user_from_session(&state) else {
return axum::response::Html(r#"
+ Add email account
"#.to_string());
};
let conn = state.conn.clone();
let accounts = tokio::task::spawn_blocking(move || {
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {e}"))?;
diesel::sql_query(
"SELECT id, email, display_name, is_primary FROM user_email_accounts WHERE user_id = $1 AND is_active = true ORDER BY is_primary DESC"
)
.bind::(user_id)
.load::(&mut db_conn)
.map_err(|e| format!("Query failed: {e}"))
})
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
if accounts.is_empty() {
return axum::response::Html(r#"
+ Add email account
"#.to_string());
}
let mut html = String::new();
for account in accounts {
let name = account
.display_name
.clone()
.unwrap_or_else(|| account.email.clone());
let primary_badge = if account.is_primary {
r#"Primary"#
} else {
""
};
use std::fmt::Write;
let _ = write!(
html,
r#"
{}
{}
"#,
account.id, name, primary_badge
);
}
axum::response::Html(html)
}
pub async fn list_email_accounts(
State(state): State>,
) -> Result>>, EmailError> {
let Ok(current_user_id) = extract_user_from_session(&state) else {
return Err(EmailError("Authentication required".to_string()));
};
let conn = state.conn.clone();
let accounts = tokio::task::spawn_blocking(move || {
use crate::shared::models::schema::user_email_accounts::dsl::{
created_at, display_name, email, id, imap_port, imap_server, is_active, is_primary,
smtp_port, smtp_server, user_email_accounts, user_id,
};
let mut db_conn = conn
.get()
.map_err(|e| format!("DB connection error: {e}"))?;
let results = user_email_accounts
.filter(user_id.eq(current_user_id))
.filter(is_active.eq(true))
.order((is_primary.desc(), created_at.desc()))
.select((
id,
email,
display_name,
imap_server,
imap_port,
smtp_server,
smtp_port,
is_primary,
is_active,
created_at,
))
.load::<(
Uuid,
String,
Option,
String,
i32,
String,
i32,
bool,
bool,
chrono::DateTime,
)>(&mut db_conn)
.map_err(|e| format!("Query failed: {e}"))?;
Ok::<_, String>(results)
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
let account_list: Vec = accounts
.into_iter()
.map(
|(
acc_id,
acc_email,
acc_display_name,
acc_imap_server,
acc_imap_port,
acc_smtp_server,
acc_smtp_port,
acc_is_primary,
acc_is_active,
acc_created_at,
)| {
EmailAccountResponse {
id: acc_id.to_string(),
email: acc_email,
display_name: acc_display_name,
imap_server: acc_imap_server,
imap_port: acc_imap_port as u16,
smtp_server: acc_smtp_server,
smtp_port: acc_smtp_port as u16,
is_primary: acc_is_primary,
is_active: acc_is_active,
created_at: acc_created_at.to_rfc3339(),
}
},
)
.collect();
Ok(Json(ApiResponse {
success: true,
data: Some(account_list),
message: None,
}))
}
pub async fn delete_email_account(
State(state): State>,
Path(account_id): Path,
) -> Result>, EmailError> {
let account_uuid =
Uuid::parse_str(&account_id).map_err(|_| EmailError("Invalid account ID".to_string()))?;
let conn = state.conn.clone();
tokio::task::spawn_blocking(move || {
let mut db_conn = conn
.get()
.map_err(|e| format!("DB connection error: {e}"))?;
diesel::sql_query("UPDATE user_email_accounts SET is_active = false WHERE id = $1")
.bind::(account_uuid)
.execute(&mut db_conn)
.map_err(|e| format!("Failed to delete account: {e}"))?;
Ok::<_, String>(())
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
Ok(Json(ApiResponse {
success: true,
data: Some(()),
message: Some("Email account deleted".to_string()),
}))
}
pub async fn list_emails(
State(state): State>,
Json(request): Json,
) -> Result>>, EmailError> {
let account_uuid = Uuid::parse_str(&request.account_id)
.map_err(|_| EmailError("Invalid account ID".to_string()))?;
let conn = state.conn.clone();
let account_info = tokio::task::spawn_blocking(move || {
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {e}"))?;
let result: ImapCredentialsRow = diesel::sql_query(
"SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true"
)
.bind::(account_uuid)
.get_result(&mut db_conn)
.map_err(|e| format!("Account not found: {e}"))?;
Ok::<_, String>(result)
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
let (imap_server, imap_port, username, encrypted_password) = (
account_info.imap_server,
account_info.imap_port,
account_info.username,
account_info.password_encrypted,
);
let password = decrypt_password(&encrypted_password).map_err(EmailError)?;
let client = imap::ClientBuilder::new(imap_server.as_str(), imap_port as u16)
.connect()
.map_err(|e| EmailError(format!("Failed to connect to IMAP: {e:?}")))?;
let mut session = client
.login(&username, &password)
.map_err(|e| EmailError(format!("Login failed: {e:?}")))?;
let folder = request.folder.unwrap_or_else(|| "INBOX".to_string());
session
.select(&folder)
.map_err(|e| EmailError(format!("Failed to select folder: {e:?}")))?;
let messages = session
.search("ALL")
.map_err(|e| EmailError(format!("Failed to search emails: {e:?}")))?;
let mut email_list = Vec::new();
let limit = request.limit.unwrap_or(50);
let offset = request.offset.unwrap_or(0);
let mut recent_messages: Vec = messages.iter().copied().collect();
recent_messages.sort_by(|a, b| b.cmp(a));
let recent_messages: Vec = recent_messages
.into_iter()
.skip(offset)
.take(limit)
.collect();
for seq in recent_messages {
let fetch_result = session.fetch(seq.to_string(), "RFC822");
let messages =
fetch_result.map_err(|e| EmailError(format!("Failed to fetch email: {e:?}")))?;
for msg in messages.iter() {
let body = msg
.body()
.ok_or_else(|| EmailError("No body found".to_string()))?;
let parsed = parse_mail(body)
.map_err(|e| EmailError(format!("Failed to parse email: {e:?}")))?;
let headers = parsed.get_headers();
let subject = headers.get_first_value("Subject").unwrap_or_default();
let from = headers.get_first_value("From").unwrap_or_default();
let to = headers.get_first_value("To").unwrap_or_default();
let date = headers.get_first_value("Date").unwrap_or_default();
let body_text = parsed
.subparts
.iter()
.find(|p| p.ctype.mimetype == "text/plain")
.map_or_else(
|| parsed.get_body().unwrap_or_default(),
|body_part| body_part.get_body().unwrap_or_default(),
);
let body_html = parsed
.subparts
.iter()
.find(|p| p.ctype.mimetype == "text/html")
.map_or_else(String::new, |body_part| {
body_part.get_body().unwrap_or_default()
});
let preview = body_text.lines().take(3).collect::>().join(" ");
let preview_truncated = if preview.len() > 150 {
format!("{}...", &preview[..150])
} else {
preview
};
let (from_name, from_email) = parse_from_field(&from);
let has_attachments = parsed.subparts.iter().any(|p| {
p.get_content_disposition().disposition == mailparse::DispositionType::Attachment
});
email_list.push(EmailResponse {
id: seq.to_string(),
from_name,
from_email,
to,
subject,
preview: preview_truncated,
body: if body_html.is_empty() {
body_text
} else {
body_html
},
date: format_email_time(&date),
time: format_email_time(&date),
read: false,
folder: folder.clone(),
has_attachments,
});
}
}
session.logout().ok();
Ok(Json(ApiResponse {
success: true,
data: Some(email_list),
message: None,
}))
}
pub async fn send_email(
State(state): State>,
Json(request): Json,
) -> Result>, EmailError> {
let account_uuid = Uuid::parse_str(&request.account_id)
.map_err(|_| EmailError("Invalid account ID".to_string()))?;
let conn = state.conn.clone();
let account_info = tokio::task::spawn_blocking(move || {
let mut db_conn = conn
.get()
.map_err(|e| format!("DB connection error: {e}"))?;
let result: SmtpCredentialsRow = diesel::sql_query(
"SELECT email, display_name, smtp_port, smtp_server, username, password_encrypted
FROM user_email_accounts WHERE id = $1 AND is_active = true",
)
.bind::(account_uuid)
.get_result(&mut db_conn)
.map_err(|e| format!("Account not found: {e}"))?;
Ok::<_, String>(result)
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
let (from_email, display_name, smtp_port, smtp_server, username, encrypted_password) = (
account_info.email,
account_info.display_name,
account_info.smtp_port,
account_info.smtp_server,
account_info.username,
account_info.password_encrypted,
);
let password = decrypt_password(&encrypted_password).map_err(EmailError)?;
let from_addr = if display_name.is_empty() {
from_email.clone()
} else {
format!("{display_name} <{from_email}>")
};
let pixel_enabled = is_tracking_pixel_enabled(&state, None);
let tracking_id = Uuid::new_v4();
let final_body = if pixel_enabled && request.is_html {
inject_tracking_pixel(&request.body, &tracking_id.to_string(), &state)
} else {
request.body.clone()
};
let mut email_builder = Message::builder()
.from(
from_addr
.parse()
.map_err(|e| EmailError(format!("Invalid from address: {e}")))?,
)
.to(request
.to
.parse()
.map_err(|e| EmailError(format!("Invalid to address: {e}")))?)
.subject(request.subject.clone());
if let Some(ref cc) = request.cc {
email_builder = email_builder.cc(cc
.parse()
.map_err(|e| EmailError(format!("Invalid cc address: {e}")))?);
}
if let Some(ref bcc) = request.bcc {
email_builder = email_builder.bcc(
bcc.parse()
.map_err(|e| EmailError(format!("Invalid bcc address: {e}")))?,
);
}
let email = email_builder
.body(final_body)
.map_err(|e| EmailError(format!("Failed to build email: {e}")))?;
let creds = Credentials::new(username, password);
let mailer = SmtpTransport::relay(&smtp_server)
.map_err(|e| EmailError(format!("Failed to create SMTP transport: {e}")))?
.port(u16::try_from(smtp_port).unwrap_or(587))
.credentials(creds)
.build();
mailer
.send(&email)
.map_err(|e| EmailError(format!("Failed to send email: {e}")))?;
if pixel_enabled {
let conn = state.conn.clone();
let to_email = request.to.clone();
let subject = request.subject.clone();
let cc_clone = request.cc.clone();
let bcc_clone = request.bcc.clone();
let _ = tokio::task::spawn_blocking(move || {
save_email_tracking_record(
conn,
tracking_id,
account_uuid,
Uuid::nil(),
&from_email,
&to_email,
cc_clone.as_deref(),
bcc_clone.as_deref(),
&subject,
)
})
.await;
}
info!("Email sent successfully from account {account_uuid} with tracking_id {tracking_id}");
Ok(Json(ApiResponse {
success: true,
data: Some(()),
message: Some("Email sent successfully".to_string()),
}))
}
pub async fn save_draft(
State(state): State>,
Json(request): Json,
) -> Result, EmailError> {
let account_uuid = Uuid::parse_str(&request.account_id)
.map_err(|_| EmailError("Invalid account ID".to_string()))?;
let Ok(user_id) = extract_user_from_session(&state) else {
return Err(EmailError("Authentication required".to_string()));
};
let draft_id = Uuid::new_v4();
let conn = state.conn.clone();
tokio::task::spawn_blocking(move || {
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {e}"))?;
diesel::sql_query(
"INSERT INTO email_drafts (id, user_id, account_id, to_address, cc_address, bcc_address, subject, body)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
)
.bind::(draft_id)
.bind::(user_id)
.bind::(account_uuid)
.bind::(&request.to)
.bind::, _>(request.cc.as_ref())
.bind::, _>(request.bcc.as_ref())
.bind::(&request.subject)
.bind::(&request.body)
.execute(&mut db_conn)
.map_err(|e| format!("Failed to save draft: {e}"))?;
Ok::<_, String>(())
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
Ok(Json(SaveDraftResponse {
success: true,
draft_id: Some(draft_id.to_string()),
message: "Draft saved successfully".to_string(),
}))
}
pub async fn list_folders(
State(state): State>,
Path(account_id): Path,
) -> Result>>, EmailError> {
let account_uuid =
Uuid::parse_str(&account_id).map_err(|_| EmailError("Invalid account ID".to_string()))?;
let conn = state.conn.clone();
let account_info = tokio::task::spawn_blocking(move || {
let mut db_conn = conn.get().map_err(|e| format!("DB connection error: {e}"))?;
let result: ImapCredentialsRow = diesel::sql_query(
"SELECT imap_server, imap_port, username, password_encrypted FROM user_email_accounts WHERE id = $1 AND is_active = true"
)
.bind::(account_uuid)
.get_result(&mut db_conn)
.map_err(|e| format!("Account not found: {e}"))?;
Ok::<_, String>(result)
})
.await
.map_err(|e| EmailError(format!("Task join error: {e}")))?
.map_err(EmailError)?;
let (imap_server, imap_port, username, encrypted_password) = (
account_info.imap_server,
account_info.imap_port,
account_info.username,
account_info.password_encrypted,
);
let password = decrypt_password(&encrypted_password).map_err(EmailError)?;
let client = imap::ClientBuilder::new(imap_server.as_str(), imap_port as u16)
.connect()
.map_err(|e| EmailError(format!("Failed to connect to IMAP: {e:?}")))?;
let mut session = client
.login(&username, &password)
.map_err(|e| EmailError(format!("Login failed: {e:?}")))?;
let folders = session
.list(None, Some("*"))
.map_err(|e| EmailError(format!("Failed to list folders: {e:?}")))?;
let folder_list: Vec = folders
.iter()
.map(|f| FolderInfo {
name: f.name().to_string(),
path: f.name().to_string(),
unread_count: 0,
total_count: 0,
})
.collect();
session.logout().ok();
Ok(Json(ApiResponse {
success: true,
data: Some(folder_list),
message: None,
}))
}
pub fn get_latest_email_from(
State(_state): State>,
Json(_request): Json,
) -> Result, EmailError> {
Ok(Json(serde_json::json!({
"success": false,
"message": "Please use the new /api/email/list endpoint with account_id"
})))
}
pub fn save_click(
Path((campaign_id, email)): Path<(String, String)>,
State(_state): State>,
) -> impl IntoResponse {
info!(
"Click tracked - Campaign: {}, Email: {}",
campaign_id, email
);
let pixel: Vec = vec![
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xFF, 0xFF,
0xFF, 0x00, 0x00, 0x00, 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00,
0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B,
];
(StatusCode::OK, [("content-type", "image/gif")], pixel)
}
const TRACKING_PIXEL: [u8; 43] = [
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
0x00, 0x00, 0x00, 0x21, 0xF9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B,
];
fn is_tracking_pixel_enabled(state: &Arc, bot_id: Option) -> bool {
let config_manager = crate::core::config::ConfigManager::new(state.conn.clone());
let bot_id = bot_id.unwrap_or(Uuid::nil());
config_manager
.get_config(&bot_id, "email-read-pixel", Some("false"))
.map(|v| v.to_lowercase() == "true")
.unwrap_or(false)
}
fn inject_tracking_pixel(html_body: &str, tracking_id: &str, state: &Arc) -> String {
let config_manager = crate::core::config::ConfigManager::new(state.conn.clone());
let base_url = config_manager
.get_config(&Uuid::nil(), "server-url", Some("http://localhost:8080"))
.unwrap_or_else(|_| "http://localhost:8080".to_string());
let pixel_url = format!("{}/api/email/tracking/pixel/{}", base_url, tracking_id);
let pixel_html = format!(
r#"
"#,
pixel_url
);
if html_body.to_lowercase().contains("