feat(phase1): wire email notifications, shared email crate, AppState for services
- Create crates/email shared Mailer with 18+ templates (auth, approvals, jobs, leads, tracecoins)
- users/mail.rs now re-exports from shared crate (lettre dep removed)
- Wire password changed/reset emails in users auth handlers
- Wire profile approval/rejection emails in users approvals handlers (company, customer, all 9 professional types)
- Wire job approved/rejected emails in users approvals handlers
- Wire requirement approved email in users approvals handlers
- Add AppState (pool + mail) to companies service; wire submit_job and update_application_status emails
- Add AppState (pool + mail) to customers service; wire submit_requirement, approve_request, reject_request emails (incl. contact-exchange on lead acceptance)
- Add AppState (pool + storage) to job_seekers service with resume upload multipart handler
- Wire lead cancellation and accepted-leads handlers in contracts/profession_shared.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:42:48 +02:00
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 \n Your verification code is: {} \n \n This code expires in 15 minutes. \n \n Regards, \n The 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 \n Click to reset your password: \n {}/reset-password?token={} \n \n If you did not request this, ignore this email. \n \n Regards, \n The 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 \n Your password was successfully changed. If you did not do this, contact support immediately. \n \n Regards, \n The 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 \n Your account has been suspended. \n \n Reason: {} \n \n If you believe this is a mistake, contact support. \n \n Regards, \n The NXTGAUGE Team " ,
name , reason
) ,
) . 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 \n Thank you for submitting your {} profile on NXTGAUGE. Our team will review it within 1– 2 business days. \n \n You will receive an email once your profile is approved. \n \n Regards, \n The 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 \n Great news! Your {} profile on NXTGAUGE has been approved. You can now access your full dashboard. \n \n {}/dashboard \n \n Regards, \n The 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 \n Unfortunately, we were unable to approve your {} profile at this time. \n \n Reason: {} \n \n Please update your profile and resubmit. If you have questions, contact support. \n \n Regards, \n The 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 \n Your job posting \" {} \" has been submitted for review. It will go live once our team approves it (usually within 24 hours). \n \n Regards, \n The 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 \n Your job posting \" {} \" has been approved and is now live on NXTGAUGE. \n \n {}/dashboard/jobs \n \n Regards, \n The 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 \n Your job posting \" {} \" could not be approved. \n \n Reason: {} \n \n Please update and resubmit from your dashboard. \n \n Regards, \n The 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 \n Review the application: \n {}/dashboard/jobs \n \n Regards, \n The 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 \n Your application for \" {} \" has been updated: you have been {}. \n \n View details: \n {}/dashboard/applications \n \n Regards, \n The 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 \n Your requirement \" {} \" has been submitted for review and will go live once approved. \n \n Regards, \n The 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 \n Your requirement \" {} \" is now live and professionals can send you requests. \n \n {}/dashboard/requirements \n \n Regards, \n The 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 \n Review and accept/reject: \n {}/dashboard/requirements \n \n Regards, \n The 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 \n Email: {} \n Phone: {} \n \n Please reach out to them directly. \n \n Regards, \n The 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 \n You accepted {}'s request. Here are their contact details: \n \n Email: {} \n Phone: {} \n \n Regards, \n The 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 \n Your request for the requirement \" {} \" was not accepted this time. Your Tracecoins have been returned to your wallet. \n \n Keep exploring the marketplace for more opportunities! \n \n {}/dashboard/marketplace \n \n Regards, \n The 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 \n Reason: {} \n \n Regards, \n The NXTGAUGE Team " ,
name , amount , reason
) ,
) . await
}
2026-04-02 13:09:43 +02:00
// ── 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 \n Your lead request has expired because it wasn't accepted within 24 hours. \n \n We have refunded your {} reserved Tracecoins back to your wallet. \n \n Regards, \n The 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 \n Your requirement \" {} \" has expired and is no longer visible to professionals. \n \n Regards, \n The 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 \n Your job posting \" {} \" has expired and is no longer accepting applications. \n \n Regards, \n The NXTGAUGE Team " ,
company_name , title
) ,
) . await
}
feat(phase1): wire email notifications, shared email crate, AppState for services
- Create crates/email shared Mailer with 18+ templates (auth, approvals, jobs, leads, tracecoins)
- users/mail.rs now re-exports from shared crate (lettre dep removed)
- Wire password changed/reset emails in users auth handlers
- Wire profile approval/rejection emails in users approvals handlers (company, customer, all 9 professional types)
- Wire job approved/rejected emails in users approvals handlers
- Wire requirement approved email in users approvals handlers
- Add AppState (pool + mail) to companies service; wire submit_job and update_application_status emails
- Add AppState (pool + mail) to customers service; wire submit_requirement, approve_request, reject_request emails (incl. contact-exchange on lead acceptance)
- Add AppState (pool + storage) to job_seekers service with resume upload multipart handler
- Wire lead cancellation and accepted-leads handlers in contracts/profession_shared.rs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 01:42:48 +02:00
}
impl Default for Mailer {
fn default ( ) -> Self {
Self ::new ( )
}
}