use models::users::user_email_verification_codes::{self, EmailVerificationPurpose}; use models::users::user_emails; use sea_orm::sea_query::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::UserError; use crate::services::service::UserService; use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url}; pub const EMAIL_MAX_LEN: usize = 254; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct EmailDto { pub id: Uuid, pub email: String, pub is_activated: bool, pub is_primary: bool, pub created_at: OffsetDateTime, pub activated_at: Option, } impl From for EmailDto { fn from(m: user_emails::Model) -> Self { Self { id: m.id, email: m.email, is_activated: m.is_activated, is_primary: m.is_primary, created_at: m.created_at, activated_at: m.activated_at, } } } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct AddEmailCmd { pub email: String, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct VerifyAddEmailCmd { pub email: String, pub code: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AddEmailResp { pub pending: bool, pub expires_in: i64, } impl UserService { pub async fn list_emails(&self, uid: Uuid) -> Result, UserError> { let rows = user_emails::Entity::find() .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::DeletedAt.is_null()) .order_by_desc(user_emails::Column::CreatedAt) .all(self.db().read()) .await?; Ok(rows.into_iter().map(EmailDto::from).collect()) } pub async fn add_email(&self, uid: Uuid, cmd: AddEmailCmd) -> Result { let config = &self.config().user; let email = cmd.email.trim().to_lowercase(); if email.is_empty() || email.len() > EMAIL_MAX_LEN || !email.contains('@') || email.chars().any(|c| c.is_whitespace()) { return Err(UserError::BadRequest(format!( "invalid email (length 1..={EMAIL_MAX_LEN})" ))); } let rows = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&email)) .all(self.db().read()) .await?; if rows.iter().any(|row| row.user_id != uid) { return Err(UserError::Conflict("email is already in use".to_string())); } if rows .iter() .any(|row| row.is_activated && row.deleted_at.is_none()) { return Err(UserError::Conflict("already added".to_string())); } let rate_key = format!("user:email_code_rate:add_email:{email}"); let acquired = export::redis::set_nx_ex( &self.state.redis, &rate_key, "1", config.email_code_rate_limit_secs, ) .await?; if !acquired { return Err(UserError::BadRequest( "too many requests,try later".to_string(), )); } let now = OffsetDateTime::now_utc(); user_email_verification_codes::Entity::update_many() .filter(user_email_verification_codes::Column::UserId.eq(uid)) .filter( user_email_verification_codes::Column::Purpose .eq(EmailVerificationPurpose::AddEmail), ) .filter(user_email_verification_codes::Column::UsedAt.is_null()) .col_expr( user_email_verification_codes::Column::UsedAt, Expr::value(Some(now)), ) .exec(self.db().write()) .await?; let code = generate_numeric_code(config.email_verify_code_len); let expires_at = now + time::Duration::seconds(config.email_verify_code_ttl_secs); let row = user_email_verification_codes::ActiveModel { id: Set(Uuid::now_v7()), user_id: Set(uid), email: Set(email.clone()), purpose: Set(EmailVerificationPurpose::AddEmail), token_hash: Set(sha256_b64url(code.as_bytes())), created_at: Set(now), expires_at: Set(expires_at), used_at: Set(None), }; row.insert(self.db().write()).await?; // The user is signed in here, so the account preference is available. let locale = self.recipient_locale(uid).await; let text = i18n::catalog::add_email_text(locale, &code); let html = format!("

{text}

"); if let Err(error) = self .state .email .send_mail( &email, i18n::catalog::add_email_subject(locale), &text, &html, ) .await { tracing::warn!(%error, "failed to send add email verification code"); } Ok(AddEmailResp { pending: true, expires_in: config.email_verify_code_ttl_secs, }) } pub async fn verify_add_email( &self, uid: Uuid, cmd: VerifyAddEmailCmd, ) -> Result { let config = &self.config().user; let lower = cmd.email.trim().to_lowercase(); let now = OffsetDateTime::now_utc(); let Some(row) = user_email_verification_codes::Entity::find() .filter(user_email_verification_codes::Column::Email.eq(&lower)) .filter(user_email_verification_codes::Column::UserId.eq(uid)) .filter( user_email_verification_codes::Column::Purpose .eq(EmailVerificationPurpose::AddEmail), ) .filter(user_email_verification_codes::Column::UsedAt.is_null()) .order_by_desc(user_email_verification_codes::Column::CreatedAt) .one(self.db().read()) .await? else { return Err(UserError::BadRequest("no pending verification".to_string())); }; if row.expires_at <= now { return Err(UserError::BadRequest( "verification code expired".to_string(), )); } let attempts_key = format!("user:email_verify_attempts:{lower}"); let attempts = export::redis::incr_expire( &self.state.redis, &attempts_key, config.email_verify_code_ttl_secs as u64, ) .await?; if attempts > config.email_verify_max_attempts as i64 { let mut used: user_email_verification_codes::ActiveModel = row.into(); used.used_at = Set(Some(now)); used.update(self.db().write()).await?; return Err(UserError::BadRequest("too many attempts".to_string())); } let provided = sha256_b64url(cmd.code.trim().as_bytes()); if !constant_time_eq(provided.as_bytes(), row.token_hash.as_bytes()) { return Err(UserError::BadRequest( "invalid verification code".to_string(), )); } let txn = self.db().primary(); let updated = txn .transaction::<_, EmailDto, UserError>(|tx| { Box::pin(async move { let conflicts = user_emails::Entity::find() .filter(user_emails::Column::LowerEmail.eq(&lower)) .filter(user_emails::Column::UserId.ne(uid)) .one(tx) .await?; if conflicts.is_some() { return Err(UserError::Conflict("email is already in use".to_string())); } let mut used: user_email_verification_codes::ActiveModel = row.into(); used.used_at = Set(Some(now)); used.update(tx).await?; let mine = user_emails::Entity::find() .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::LowerEmail.eq(&lower)) .one(tx) .await?; let model = match mine { Some(existing) => { let mut active: user_emails::ActiveModel = existing.into(); active.email = Set(lower.clone()); active.lower_email = Set(lower.clone()); active.is_activated = Set(true); active.activated_at = Set(Some(now)); active.deleted_at = Set(None); active.updated_at = Set(now); active.update(tx).await? } None => { let active = user_emails::ActiveModel { id: Set(Uuid::now_v7()), user_id: Set(uid), email: Set(lower.clone()), lower_email: Set(lower.clone()), is_activated: Set(true), is_primary: Set(false), created_at: Set(now), updated_at: Set(now), activated_at: Set(Some(now)), deleted_at: Set(None), }; active.insert(tx).await? } }; Ok(EmailDto::from(model)) }) }) .await?; Ok(updated) } pub async fn set_primary_email( &self, uid: Uuid, email_id: Uuid, ) -> Result { let txn = self.db().primary(); let updated = txn .transaction::<_, EmailDto, UserError>(|tx| { Box::pin(async move { let target = user_emails::Entity::find_by_id(email_id) .one(tx) .await? .ok_or(UserError::NotFound)?; if target.user_id != uid { return Err(UserError::Forbidden); } if !target.is_activated { return Err(UserError::BadRequest("email not yet activated".into())); } if target.is_primary { return Err(UserError::BadRequest("email already primary".into())); } let now = OffsetDateTime::now_utc(); user_emails::Entity::update_many() .filter(user_emails::Column::UserId.eq(uid)) .filter(user_emails::Column::IsPrimary.eq(true)) .col_expr(user_emails::Column::IsPrimary, Expr::value(false)) .col_expr(user_emails::Column::UpdatedAt, Expr::value(now)) .exec(tx) .await?; let mut am: user_emails::ActiveModel = target.into(); am.is_primary = Set(true); am.updated_at = Set(now); let saved = am.update(tx).await?; Ok(EmailDto::from(saved)) }) }) .await?; Ok(updated) } pub async fn delete_email(&self, uid: Uuid, email_id: Uuid) -> Result<(), UserError> { let row = user_emails::Entity::find_by_id(email_id) .one(self.db().read()) .await? .ok_or(UserError::NotFound)?; if row.user_id != uid { return Err(UserError::Forbidden); } if row.is_primary && row.is_activated { return Err(UserError::BadRequest( "cannot delete primary activated email".into(), )); } let now = OffsetDateTime::now_utc(); let mut am: user_emails::ActiveModel = row.into(); am.deleted_at = Set(Some(now)); am.updated_at = Set(now); am.update(self.db().write()).await?; Ok(()) } }