365 lines
16 KiB
Rust
365 lines
16 KiB
Rust
use anyhow::Result;
|
||
use lettre::{
|
||
transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport,
|
||
Message, Tokio1Executor,
|
||
};
|
||
use std::env;
|
||
|
||
// ── Mailer ────────────────────────────────────────────────────────────────────
|
||
|
||
pub struct Mailer {
|
||
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
|
||
from_email: String,
|
||
from_name: String,
|
||
}
|
||
|
||
impl Mailer {
|
||
/// Build from environment variables. SMTP is optional — if vars are missing emails are
|
||
/// silently skipped so development works without a real SMTP server.
|
||
pub fn new() -> Self {
|
||
let smtp_host = env::var("SMTP_HOST").ok();
|
||
let smtp_user = env::var("SMTP_USER").ok();
|
||
let smtp_pass = env::var("SMTP_PASS").ok();
|
||
let smtp_port: u16 = env::var("SMTP_PORT")
|
||
.ok()
|
||
.and_then(|p| p.parse().ok())
|
||
.unwrap_or(587);
|
||
|
||
let from_email = env::var("SMTP_FROM_EMAIL")
|
||
.unwrap_or_else(|_| "noreply@nxtgauge.com".to_string());
|
||
let from_name = env::var("SMTP_FROM_NAME")
|
||
.unwrap_or_else(|_| "NXTGAUGE".to_string());
|
||
|
||
let transport = match (smtp_host, smtp_user, smtp_pass) {
|
||
(Some(host), Some(user), Some(pass)) => {
|
||
let creds = Credentials::new(user, pass);
|
||
match AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host) {
|
||
Ok(builder) => {
|
||
let t = builder.port(smtp_port).credentials(creds).build();
|
||
tracing::info!("SMTP transport configured (host={}:{})", host, smtp_port);
|
||
Some(t)
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("SMTP transport init failed: {} — emails disabled", e);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
tracing::warn!("SMTP_HOST/SMTP_USER/SMTP_PASS not all set — email disabled");
|
||
None
|
||
}
|
||
};
|
||
|
||
Self { transport, from_email, from_name }
|
||
}
|
||
|
||
async fn send(&self, to: &str, subject: &str, body: String) -> Result<()> {
|
||
let Some(transport) = &self.transport else {
|
||
tracing::debug!("SMTP disabled — skipping email to {} (subject: {})", to, subject);
|
||
return Ok(());
|
||
};
|
||
let email = Message::builder()
|
||
.from(format!("{} <{}>", self.from_name, self.from_email).parse()?)
|
||
.to(to.parse()?)
|
||
.subject(subject)
|
||
.body(body)?;
|
||
transport.send(email).await?;
|
||
Ok(())
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||
|
||
pub async fn send_verification_email(&self, to: &str, name: &str, otp: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Verify your NXTGAUGE account",
|
||
format!(
|
||
"Hello {},\n\nYour verification code is: {}\n\nThis code expires in 15 minutes.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, otp
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_password_reset_email(&self, to: &str, name: &str, token: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
"Reset your NXTGAUGE password",
|
||
format!(
|
||
"Hello {},\n\nClick to reset your password:\n{}/reset-password?token={}\n\nIf you did not request this, ignore this email.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, frontend_url, token
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_password_changed_email(&self, to: &str, name: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your NXTGAUGE password was changed",
|
||
format!(
|
||
"Hello {},\n\nYour password was successfully changed. If you did not do this, contact support immediately.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_account_suspended_email(&self, to: &str, name: &str, reason: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your NXTGAUGE account has been suspended",
|
||
format!(
|
||
"Hello {},\n\nYour account has been suspended.\n\nReason: {}\n\nIf you believe this is a mistake, contact support.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, reason
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_account_deleted_email(&self, to: &str, name: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your NXTGAUGE account has been deleted",
|
||
format!(
|
||
"Hello {},\n\nYour account has been deleted as requested. If this was not you, please contact support immediately.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name
|
||
),
|
||
).await
|
||
}
|
||
|
||
// ── Onboarding & Approvals ────────────────────────────────────────────────
|
||
|
||
pub async fn send_onboarding_submitted_email(&self, to: &str, name: &str, role: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your NXTGAUGE profile is under review",
|
||
format!(
|
||
"Hello {},\n\nThank you for submitting your {} profile on NXTGAUGE. Our team will review it within 1–2 business days.\n\nYou will receive an email once your profile is approved.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, role
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_approval_approved_email(&self, to: &str, name: &str, role: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
"Your NXTGAUGE profile is approved!",
|
||
format!(
|
||
"Hello {},\n\nGreat news! Your {} profile on NXTGAUGE has been approved. You can now access your full dashboard.\n\n{}/dashboard\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, role, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_approval_rejected_email(&self, to: &str, name: &str, role: &str, reason: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Update required on your NXTGAUGE profile",
|
||
format!(
|
||
"Hello {},\n\nUnfortunately, we were unable to approve your {} profile at this time.\n\nReason: {}\n\nPlease update your profile and resubmit. If you have questions, contact support.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, role, reason
|
||
),
|
||
).await
|
||
}
|
||
|
||
// ── Jobs (Company) ────────────────────────────────────────────────────────
|
||
|
||
pub async fn send_job_submitted_email(&self, to: &str, company_name: &str, job_title: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your job posting is under review",
|
||
format!(
|
||
"Hello {},\n\nYour job posting \"{}\" has been submitted for review. It will go live once our team approves it (usually within 24 hours).\n\nRegards,\nThe NXTGAUGE Team",
|
||
company_name, job_title
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_job_approved_email(&self, to: &str, company_name: &str, job_title: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
"Your job posting is now live!",
|
||
format!(
|
||
"Hello {},\n\nYour job posting \"{}\" has been approved and is now live on NXTGAUGE.\n\n{}/dashboard/jobs\n\nRegards,\nThe NXTGAUGE Team",
|
||
company_name, job_title, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_job_rejected_email(&self, to: &str, company_name: &str, job_title: &str, reason: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your job posting needs updates",
|
||
format!(
|
||
"Hello {},\n\nYour job posting \"{}\" could not be approved.\n\nReason: {}\n\nPlease update and resubmit from your dashboard.\n\nRegards,\nThe NXTGAUGE Team",
|
||
company_name, job_title, reason
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_new_application_email(&self, to: &str, company_name: &str, job_title: &str, applicant_name: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
&format!("New application for \"{}\"", job_title),
|
||
format!(
|
||
"Hello {},\n\n{} has applied for your job posting \"{}\".\n\nReview the application:\n{}/dashboard/jobs\n\nRegards,\nThe NXTGAUGE Team",
|
||
company_name, applicant_name, job_title, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_application_status_email(&self, to: &str, applicant_name: &str, job_title: &str, status: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
let status_label = match status {
|
||
"SHORTLISTED" => "shortlisted",
|
||
"INTERVIEW" => "selected for an interview",
|
||
"OFFERED" => "offered the position",
|
||
"HIRED" => "hired",
|
||
"REJECTED" => "not selected at this time",
|
||
_ => status,
|
||
};
|
||
self.send(
|
||
to,
|
||
&format!("Update on your application for \"{}\"", job_title),
|
||
format!(
|
||
"Hello {},\n\nYour application for \"{}\" has been updated: you have been {}.\n\nView details:\n{}/dashboard/applications\n\nRegards,\nThe NXTGAUGE Team",
|
||
applicant_name, job_title, status_label, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
// ── Requirements (Customer) ───────────────────────────────────────────────
|
||
|
||
pub async fn send_requirement_submitted_email(&self, to: &str, name: &str, title: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your requirement is under review",
|
||
format!(
|
||
"Hello {},\n\nYour requirement \"{}\" has been submitted for review and will go live once approved.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, title
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_requirement_approved_email(&self, to: &str, name: &str, title: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
"Your requirement is now live!",
|
||
format!(
|
||
"Hello {},\n\nYour requirement \"{}\" is now live and professionals can send you requests.\n\n{}/dashboard/requirements\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, title, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_lead_request_received_email(&self, to: &str, customer_name: &str, requirement_title: &str, professional_name: &str) -> Result<()> {
|
||
let frontend_url = env::var("FRONTEND_URL")
|
||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||
self.send(
|
||
to,
|
||
&format!("New request for \"{}\"", requirement_title),
|
||
format!(
|
||
"Hello {},\n\n{} is interested in your requirement \"{}\" and has sent a request to connect.\n\nReview and accept/reject:\n{}/dashboard/requirements\n\nRegards,\nThe NXTGAUGE Team",
|
||
customer_name, professional_name, requirement_title, frontend_url
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_lead_accepted_professional_email(&self, to: &str, professional_name: &str, customer_name: &str, customer_email: &str, customer_phone: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your lead request was accepted!",
|
||
format!(
|
||
"Hello {},\n\n{} has accepted your request. Here are their contact details:\n\nEmail: {}\nPhone: {}\n\nPlease reach out to them directly.\n\nRegards,\nThe NXTGAUGE Team",
|
||
professional_name, customer_name, customer_email, customer_phone
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_lead_accepted_customer_email(&self, to: &str, customer_name: &str, professional_name: &str, professional_email: &str, professional_phone: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"You accepted a professional request",
|
||
format!(
|
||
"Hello {},\n\nYou accepted {}'s request. Here are their contact details:\n\nEmail: {}\nPhone: {}\n\nRegards,\nThe NXTGAUGE Team",
|
||
customer_name, professional_name, professional_email, professional_phone
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_lead_rejected_email(&self, to: &str, professional_name: &str, requirement_title: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your lead request was not accepted",
|
||
format!(
|
||
"Hello {},\n\nYour request for the requirement \"{}\" was not accepted this time. Your Tracecoins have been returned to your wallet.\n\nKeep exploring the marketplace for more opportunities!\n\n{}/dashboard/marketplace\n\nRegards,\nThe NXTGAUGE Team",
|
||
professional_name,
|
||
requirement_title,
|
||
env::var("FRONTEND_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||
),
|
||
).await
|
||
}
|
||
|
||
// ── Tracecoins ────────────────────────────────────────────────────────────
|
||
|
||
pub async fn send_manual_credit_email(&self, to: &str, name: &str, amount: i32, reason: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Tracecoins credited to your account",
|
||
format!(
|
||
"Hello {},\n\n{} Tracecoins have been credited to your NXTGAUGE wallet.\n\nReason: {}\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, amount, reason
|
||
),
|
||
).await
|
||
}
|
||
|
||
// ── Expiry (Cron) ─────────────────────────────────────────────────────────
|
||
|
||
pub async fn send_lead_expired_email(&self, to: &str, name: &str, tracecoins_returned: i32) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your lead request has expired",
|
||
format!(
|
||
"Hello {},\n\nYour lead request has expired because it wasn't accepted within 24 hours.\n\nWe have refunded your {} reserved Tracecoins back to your wallet.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, tracecoins_returned
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_requirement_expired_email(&self, to: &str, name: &str, title: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your requirement has expired",
|
||
format!(
|
||
"Hello {},\n\nYour requirement \"{}\" has expired and is no longer visible to professionals.\n\nRegards,\nThe NXTGAUGE Team",
|
||
name, title
|
||
),
|
||
).await
|
||
}
|
||
|
||
pub async fn send_job_expired_email(&self, to: &str, company_name: &str, title: &str) -> Result<()> {
|
||
self.send(
|
||
to,
|
||
"Your job posting has expired",
|
||
format!(
|
||
"Hello {},\n\nYour job posting \"{}\" has expired and is no longer accepting applications.\n\nRegards,\nThe NXTGAUGE Team",
|
||
company_name, title
|
||
),
|
||
).await
|
||
}
|
||
}
|
||
|
||
impl Default for Mailer {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|