- 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
549 lines
22 KiB
Rust
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()
|
|
}
|