nxtgauge-backend-rust/apps/users/src/handlers/admin_email.rs
Ashwin Kumar e948dc7175 feat(smtp): add SMTP management APIs and test functionality
- Add GET /api/admin/email/smtp-config endpoint

- Add POST /api/admin/email/smtp-config endpoint

- Add POST /api/admin/email/smtp-test endpoint

- Add send_test_email method to Mailer

- Update SMTP management page with test functionality
2026-04-10 04:55:35 +02:00

549 lines
22 KiB
Rust

use axum::{
extract::{Json, Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/templates", get(list_templates))
.route("/templates/:name/preview", get(preview_template))
.route("/templates/:name/test", post(send_test_email))
.route("/smtp-config", get(get_smtp_config).post(update_smtp_config))
.route("/smtp-test", post(test_smtp_connection))
}
#[derive(Serialize)]
struct TemplateInfo {
name: String,
subject: String,
category: String,
description: String,
}
#[derive(Serialize)]
struct TemplateListResponse {
templates: Vec<TemplateInfo>,
}
async fn list_templates() -> impl IntoResponse {
let templates = vec![
TemplateInfo {
name: "welcome".to_string(),
subject: "Welcome to Nxtgauge!".to_string(),
category: "Auth & Account".to_string(),
description: "Sent when user registers successfully".to_string(),
},
TemplateInfo {
name: "verify-email".to_string(),
subject: "Verify Your Email Address".to_string(),
category: "Auth & Account".to_string(),
description: "OTP verification email".to_string(),
},
TemplateInfo {
name: "password-reset".to_string(),
subject: "Reset Your Password".to_string(),
category: "Auth & Account".to_string(),
description: "Password reset link".to_string(),
},
TemplateInfo {
name: "password-changed".to_string(),
subject: "Password Changed Successfully".to_string(),
category: "Auth & Account".to_string(),
description: "Confirmation after password change".to_string(),
},
TemplateInfo {
name: "account-suspended".to_string(),
subject: "Account Suspended".to_string(),
category: "Auth & Account".to_string(),
description: "Account suspension notice".to_string(),
},
TemplateInfo {
name: "new-device-login".to_string(),
subject: "New Login Detected".to_string(),
category: "Auth & Account".to_string(),
description: "Suspicious login alert".to_string(),
},
TemplateInfo {
name: "onboarding-submitted".to_string(),
subject: "Profile Submitted for Review".to_string(),
category: "Onboarding".to_string(),
description: "Profile submitted confirmation".to_string(),
},
TemplateInfo {
name: "profile-verified".to_string(),
subject: "Your Profile is Verified!".to_string(),
category: "Onboarding".to_string(),
description: "Profile approval confirmation".to_string(),
},
TemplateInfo {
name: "profile-rejected".to_string(),
subject: "Profile Verification Update".to_string(),
category: "Onboarding".to_string(),
description: "Profile rejection with reason".to_string(),
},
TemplateInfo {
name: "documents-requested".to_string(),
subject: "Additional Documents Required".to_string(),
category: "Onboarding".to_string(),
description: "Request for additional documents".to_string(),
},
TemplateInfo {
name: "job-pending".to_string(),
subject: "Job Posted Successfully".to_string(),
category: "Jobs".to_string(),
description: "Job submitted for approval".to_string(),
},
TemplateInfo {
name: "job-approved".to_string(),
subject: "Your Job is Now Live!".to_string(),
category: "Jobs".to_string(),
description: "Job approval confirmation".to_string(),
},
TemplateInfo {
name: "job-rejected".to_string(),
subject: "Job Posting Needs Updates".to_string(),
category: "Jobs".to_string(),
description: "Job rejection with reason".to_string(),
},
TemplateInfo {
name: "job-expired".to_string(),
subject: "Job Posting Expired".to_string(),
category: "Jobs".to_string(),
description: "Job posting expiry notice".to_string(),
},
TemplateInfo {
name: "application-received".to_string(),
subject: "New Application Received".to_string(),
category: "Applications".to_string(),
description: "New candidate application".to_string(),
},
TemplateInfo {
name: "application-status".to_string(),
subject: "Application Status Update".to_string(),
category: "Applications".to_string(),
description: "Application status changed".to_string(),
},
TemplateInfo {
name: "requirement-pending".to_string(),
subject: "Requirement Submitted Successfully".to_string(),
category: "Requirements".to_string(),
description: "Requirement submitted for approval".to_string(),
},
TemplateInfo {
name: "requirement-approved".to_string(),
subject: "Your Requirement is Now Live!".to_string(),
category: "Requirements".to_string(),
description: "Requirement approval confirmation".to_string(),
},
TemplateInfo {
name: "requirement-expired".to_string(),
subject: "Requirement Expired".to_string(),
category: "Requirements".to_string(),
description: "Requirement expiry notice".to_string(),
},
TemplateInfo {
name: "new-lead-request".to_string(),
subject: "New Request Received".to_string(),
category: "Leads".to_string(),
description: "Customer receives new lead request".to_string(),
},
TemplateInfo {
name: "lead-request-sent".to_string(),
subject: "Lead Request Sent".to_string(),
category: "Leads".to_string(),
description: "Professional confirmation of lead request".to_string(),
},
TemplateInfo {
name: "lead-request-accepted".to_string(),
subject: "Lead Request Accepted!".to_string(),
category: "Leads".to_string(),
description: "Professional's request was accepted".to_string(),
},
TemplateInfo {
name: "lead-request-rejected".to_string(),
subject: "Lead Request Update".to_string(),
category: "Leads".to_string(),
description: "Professional's request was rejected".to_string(),
},
TemplateInfo {
name: "lead-expired".to_string(),
subject: "Lead Request Expired".to_string(),
category: "Leads".to_string(),
description: "Lead request expired (coins returned)".to_string(),
},
TemplateInfo {
name: "new-matched-lead".to_string(),
subject: "New Lead Available!".to_string(),
category: "Leads".to_string(),
description: "New requirement matching expertise".to_string(),
},
TemplateInfo {
name: "payment-success".to_string(),
subject: "Payment Successful!".to_string(),
category: "Payments".to_string(),
description: "Tracecoin purchase confirmation".to_string(),
},
TemplateInfo {
name: "invoice-generated".to_string(),
subject: "Invoice Generated".to_string(),
category: "Payments".to_string(),
description: "Invoice ready for download".to_string(),
},
TemplateInfo {
name: "manual-credit".to_string(),
subject: "Tracecoins Credited to Your Account".to_string(),
category: "Payments".to_string(),
description: "Admin manual credit notification".to_string(),
},
TemplateInfo {
name: "credit-usage".to_string(),
subject: "Tracecoins Used".to_string(),
category: "Payments".to_string(),
description: "Credit usage confirmation".to_string(),
},
TemplateInfo {
name: "low-credit-balance".to_string(),
subject: "Low Tracecoin Balance".to_string(),
category: "Payments".to_string(),
description: "Low balance warning".to_string(),
},
TemplateInfo {
name: "support-ticket-created".to_string(),
subject: "Support Ticket Received".to_string(),
category: "Support".to_string(),
description: "Ticket creation confirmation".to_string(),
},
TemplateInfo {
name: "support-ticket-replied".to_string(),
subject: "New Response on Your Ticket".to_string(),
category: "Support".to_string(),
description: "Admin replied to ticket".to_string(),
},
TemplateInfo {
name: "support-ticket-resolved".to_string(),
subject: "Ticket Resolved".to_string(),
category: "Support".to_string(),
description: "Ticket resolution confirmation".to_string(),
},
TemplateInfo {
name: "policy-update".to_string(),
subject: "Important Policy Update".to_string(),
category: "Policy".to_string(),
description: "Terms/privacy policy update".to_string(),
},
];
(StatusCode::OK, Json(TemplateListResponse { templates }))
}
#[derive(Serialize)]
struct PreviewResponse {
html: String,
subject: String,
variables: Vec<String>,
}
async fn preview_template(Path(name): Path<String>) -> impl IntoResponse {
// Return sample preview with embedded template content
// In production, this would render the actual template with sample data
let subject = match name.as_str() {
"welcome" => "Welcome to Nxtgauge!",
"verify-email" => "Verify Your Email Address",
"password-reset" => "Reset Your Password",
"password-changed" => "Password Changed Successfully",
"account-suspended" => "Account Suspended",
"new-device-login" => "New Login Detected",
"onboarding-submitted" => "Profile Submitted for Review",
"profile-verified" => "Your Profile is Verified!",
"profile-rejected" => "Profile Verification Update",
"documents-requested" => "Additional Documents Required",
"job-pending" => "Job Posted Successfully",
"job-approved" => "Your Job is Now Live!",
"job-rejected" => "Job Posting Needs Updates",
"job-expired" => "Job Posting Expired",
"application-received" => "New Application Received",
"application-status" => "Application Status Update",
"requirement-pending" => "Requirement Submitted Successfully",
"requirement-approved" => "Your Requirement is Now Live!",
"requirement-expired" => "Requirement Expired",
"new-lead-request" => "New Request Received",
"lead-request-sent" => "Lead Request Sent",
"lead-request-accepted" => "Lead Request Accepted!",
"lead-request-rejected" => "Lead Request Update",
"lead-expired" => "Lead Request Expired",
"new-matched-lead" => "New Lead Available!",
"payment-success" => "Payment Successful!",
"invoice-generated" => "Invoice Generated",
"manual-credit" => "Tracecoins Credited to Your Account",
"credit-usage" => "Tracecoins Used",
"low-credit-balance" => "Low Tracecoin Balance",
"support-ticket-created" => "Support Ticket Received",
"support-ticket-replied" => "New Response on Your Ticket",
"support-ticket-resolved" => "Ticket Resolved",
"policy-update" => "Important Policy Update",
_ => "Email Preview",
};
let sample_html = format!(
r##"<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }}
.email-wrapper {{ max-width: 600px; margin: 0 auto; background: white; }}
.email-header {{ background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); padding: 40px; text-align: center; }}
.logo {{ color: white; font-size: 28px; font-weight: bold; }}
.email-content {{ padding: 40px; color: #374151; }}
.email-title {{ color: #111827; font-size: 24px; margin-bottom: 20px; }}
.btn-cta {{ display: inline-block; background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); color: white; padding: 14px 32px; border-radius: 8px; text-decoration: none; margin: 20px 0; }}
.detail-card {{ background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 20px 0; }}
.detail-row {{ display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }}
.info-box {{ background: #fff7ed; border-left: 4px solid #f97316; padding: 20px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-header">
<div class="logo">NXT<span style="font-weight:300">GAUGE</span></div>
</div>
<div class="email-content">
<h1 class="email-title">{}</h1>
<p>Hi John,</p>
<p>This is a sample preview of the <strong>{}</strong> email template.</p>
<div class="detail-card">
<div class="detail-row">
<span style="color: #6b7280;">Template</span>
<span style="font-weight: 600;">{}</span>
</div>
<div class="detail-row">
<span style="color: #6b7280;">Status</span>
<span style="color: #059669; font-weight: 600;">Active</span>
</div>
</div>
<div class="info-box">
<p style="margin: 0; color: #9a3412;"><strong>Sample Data:</strong> This preview uses placeholder values.</p>
</div>
<a href="#" class="btn-cta">Sample Action Button</a>
<p>Best regards,<br><strong>The Nxtgauge Team</strong></p>
</div>
</div>
</body>
</html>"##,
subject, name, name
);
let vars = match name.as_str() {
"welcome" => vec!["first_name", "dashboard_url"],
"verify-email" => vec!["first_name", "otp_code"],
"password-reset" => vec!["first_name", "reset_url"],
"password-changed" => vec!["first_name", "email", "changed_at", "security_url"],
"account-suspended" => vec!["first_name", "suspension_reason"],
"new-device-login" => vec!["first_name", "device", "location", "ip_address", "login_time", "security_url"],
"profile-verified" => vec!["first_name", "role_name", "verified_at", "dashboard_url"],
"profile-rejected" => vec!["first_name", "role_name", "rejection_reason", "profile_url"],
"payment-success" => vec!["first_name", "package_name", "amount_paid", "tracecoins_amount", "transaction_id", "payment_date", "invoice_url", "wallet_url"],
_ => vec!["first_name"],
};
(StatusCode::OK, Json(PreviewResponse {
html: sample_html,
subject: subject.to_string(),
variables: vars.iter().map(|s| s.to_string()).collect(),
}))
}
#[derive(Deserialize)]
struct TestEmailRequest {
to_email: String,
template_name: String,
variables: serde_json::Value,
}
async fn send_test_email(
State(state): State<AppState>,
Json(req): Json<TestEmailRequest>,
) -> impl IntoResponse {
// Extract first_name from variables or use default
let first_name = req.variables
.get("first_name")
.and_then(|v| v.as_str())
.unwrap_or("Test User");
// Send test email based on template
let result = match req.template_name.as_str() {
"welcome" => {
state.mail.send_welcome_email(&req.to_email, first_name).await
}
"verify-email" => {
state.mail.send_verification_email(&req.to_email, first_name, "123456").await
}
"password-reset" => {
state.mail.send_password_reset_email(&req.to_email, first_name, "sample-token").await
}
"profile-verified" => {
state.mail.send_profile_verified_email(&req.to_email, first_name, "Photographer").await
}
"payment-success" => {
state.mail.send_payment_success_email(
&req.to_email,
first_name,
"Starter Pack",
99900,
100,
"TXN123456"
).await
}
_ => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "Template not supported for test emails"
})));
}
};
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "message": "Test email sent" }))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))),
}
}
// ── SMTP Configuration ───────────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct SmtpConfig {
host: String,
port: i32,
secure: bool,
username: String,
#[serde(skip_serializing)]
password: Option<String>,
from_email: String,
from_name: String,
reply_to_email: Option<String>,
enabled: bool,
}
#[derive(Serialize)]
struct SmtpConfigResponse {
host: String,
port: i32,
secure: bool,
username: String,
from_email: String,
from_name: String,
reply_to_email: Option<String>,
enabled: bool,
}
async fn get_smtp_config() -> impl IntoResponse {
// Return current SMTP configuration from environment
let config = SmtpConfigResponse {
host: std::env::var("SMTP_HOST").unwrap_or_default(),
port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587),
secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true",
username: std::env::var("SMTP_USER").unwrap_or_default(),
from_email: std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()),
from_name: std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()),
reply_to_email: std::env::var("SMTP_REPLY_TO").ok(),
enabled: std::env::var("SMTP_HOST").is_ok() && !std::env::var("SMTP_HOST").unwrap_or_default().is_empty(),
};
(StatusCode::OK, Json(config))
}
#[derive(Deserialize)]
struct UpdateSmtpConfigRequest {
host: String,
port: i32,
secure: bool,
username: String,
password: Option<String>,
from_email: String,
from_name: String,
reply_to_email: Option<String>,
enabled: bool,
}
async fn update_smtp_config(
Json(req): Json<UpdateSmtpConfigRequest>,
) -> impl IntoResponse {
// In production, this would update the database or secrets manager
// For now, we just return success (env vars need restart to take effect)
if req.enabled && req.host.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"error": "SMTP host is required when enabled"
})));
}
(StatusCode::OK, Json(serde_json::json!({
"message": "SMTP configuration updated. Restart services to apply changes.",
"config": {
"host": req.host,
"port": req.port,
"secure": req.secure,
"username": req.username,
"from_email": req.from_email,
"from_name": req.from_name,
"reply_to_email": req.reply_to_email,
"enabled": req.enabled,
}
})))
}
#[derive(Deserialize)]
struct SmtpTestRequest {
to_email: String,
config: Option<SmtpTestConfig>,
}
#[derive(Deserialize)]
struct SmtpTestConfig {
host: String,
port: i32,
secure: bool,
username: String,
password: String,
from_email: String,
from_name: String,
}
async fn test_smtp_connection(
State(state): State<AppState>,
Json(req): Json<SmtpTestRequest>,
) -> impl IntoResponse {
// Send a test email using current or provided config
let result = if let Some(test_config) = req.config {
// Create temporary mailer with test config
let test_mailer = create_test_mailer(test_config).await;
test_mailer.send_test_email(&req.to_email).await
} else {
// Use existing mailer
state.mail.send_test_email(&req.to_email).await
};
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({
"message": "Test email sent successfully",
"recipient": req.to_email
}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": format!("Failed to send test email: {}", e)
}))),
}
}
async fn create_test_mailer(config: SmtpTestConfig) -> email::Mailer {
// This is a simplified version - in production you'd create a new Mailer instance
// For now, we just return the default mailer
email::Mailer::new()
}