use db::conn::sea_orm::{EntityTrait, QueryFilter}; use email::events::{EmailAddress, SendEmailRequest}; use crate::error::AuthError; use crate::features::AuthService; use crate::metrics::{self, EmailChannel, record_email_code_issued}; use super::types::{ResendCodeCmd, ResendCodeResp}; async fn verify_pow( pow: &crate::pow::PowService, proof: &crate::features::register::PowProof, expected_cid: Option<&str>, ) -> Result<(), AuthError> { let r = pow .verify_solution_once(&proof.challenge, &proof.solution, expected_cid) .await?; if !r.verified { return Err(AuthError::PowFailed("PoW solution not accepted".into())); } Ok(()) } fn purpose_to_str( p: db::user::user_email_verification_codes::EmailVerificationPurpose, ) -> &'static str { use db::user::user_email_verification_codes::EmailVerificationPurpose as P; match p { P::Signup => "signup", P::PasswordReset => "password_reset", P::EmailChange => "email_change", } } async fn send_resend_email( svc: &email::EmailService, email: &str, code: &str, purpose: db::user::user_email_verification_codes::EmailVerificationPurpose, ttl_secs: u64, ) -> Result<(), AuthError> { let subject = match purpose { db::user::user_email_verification_codes::EmailVerificationPurpose::Signup => { "Verify your account" } db::user::user_email_verification_codes::EmailVerificationPurpose::PasswordReset => { "Reset your password" } db::user::user_email_verification_codes::EmailVerificationPurpose::EmailChange => { "Confirm your new email" } }; let text = format!( "Your verification code is: {}\n\nThe code expires in {} seconds.", code, ttl_secs ); let req = SendEmailRequest { to: vec![EmailAddress::new(email)], subject: subject.to_string(), text: Some(text), ..Default::default() }; svc.send(req).await?; Ok(()) } /// Re-issue a verification code for an already-registered user. /// /// The response is always 200-shaped regardless of whether the email /// exists, mirroring the forgot-password flow, to avoid leaking which /// addresses are registered. Requires a fresh PoW solution. pub async fn resend_code( cmd: ResendCodeCmd, svc: &AuthService, expected_cid: Option<&str>, ) -> Result { let _timer = metrics::Timer::start(metrics::FEATURE_RESEND_CODE); let result = resend_code_inner(cmd, svc, expected_cid).await; if matches!(&result, Ok(r) if r.sent) { record_email_code_issued(EmailChannel::Verify); } metrics::record_outcome(metrics::FEATURE_RESEND_CODE, &result); result } async fn resend_code_inner( cmd: ResendCodeCmd, svc: &AuthService, expected_cid: Option<&str>, ) -> Result { verify_pow(&svc.pow, &cmd.pow, expected_cid).await?; let db = svc.db.writer(); let lower = cmd.email.trim().to_ascii_lowercase(); let email_row = db::user::user_emails::Entity::find() .filter(db::user::user_emails::COLUMN.lower_email.eq(lower.clone())) .filter(db::user::user_emails::COLUMN.deleted_at.is_null()) .one(&db) .await?; // Always build a temp_key so the response shape is constant. let ttl = svc.config.verify_code_ttl_secs; let temp_id = uuid::Uuid::new_v4(); let temp_key = crate::features::TempKey { name: format!( "{}resend_{}_{}", svc.config.cookie_name_prefix, purpose_to_str(cmd.purpose), lower.replace(|c: char| !c.is_ascii_alphanumeric(), "_") ), value: temp_id.to_string(), ttl_seconds: ttl, secure: svc.config.cookie_secure, http_only: true, same_site: svc.config.cookie_same_site.clone(), domain: svc.config.cookie_domain.clone(), path: Some("/".to_string()), }; let mut sent = false; if let Some(em) = email_row { let artifacts = crate::features::email_code::usecase::issue_code( crate::features::email_code::types::IssueCodeCmd { email: cmd.email.clone(), purpose: cmd.purpose, user_id: em.user_id, }, &db, &svc.kv, &svc.config, ) .await?; if let Err(e) = send_resend_email(&svc.email, &cmd.email, &artifacts.code, cmd.purpose, ttl).await { tracing::warn!(?e, email = %cmd.email, "resend email send failed"); } else { sent = true; } } Ok(ResendCodeResp { sent, temp_key }) }