from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession import httpx from notifications.models import EmailConfirmationTokens from notifications.plugins import ( CONFIRM_TOKEN_RESEND_SECONDS, CONFIRM_TOKEN_TTL_SECONDS, build_confirmation_link, compose_token, confirmation_expiry, generate_token_secret, hash_token, now_utc, render_confirmation_email, send_confirmation_mail, split_token, verify_token, ) from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result from users.models import Users class Confirmation: def __init__(self,session:AsyncSession): self.session=session async def send_confirmation(self,user): """Issue a fresh token for an already committed Users row and mail the link.""" await EmailConfirmationTokens.invalidate_tokens_for_user(self.session,str(user.id)) secret=generate_token_secret() expires_at=confirmation_expiry() row=await EmailConfirmationTokens.insert_token(self.session,{ "user_id":user.id, "email":user.email, "token_hash":hash_token(secret), "expires_at":expires_at, }) link=build_confirmation_link(compose_token(row.id,secret)) subject,html=render_confirmation_email(link,CONFIRM_TOKEN_TTL_SECONDS) try: await send_confirmation_mail(user.email,subject,html) except (httpx.HTTPError,RuntimeError) as e: await EmailConfirmationTokens.mark_used(self.session,str(row.id)) raise HTTPException(status_code=502,detail="Failed to send confirmation email") from e return serialize_confirmation_request(user.email,expires_at) async def confirm(self,token): record_id,secret=split_token(token) if not record_id: raise HTTPException(status_code=400,detail="Invalid confirmation link") row=await EmailConfirmationTokens.get_token_by_id(self.session,record_id) if not row or not verify_token(secret,row.token_hash): raise HTTPException(status_code=400,detail="Invalid confirmation link") user=await Users.get_user_by_id(self.session,str(row.user_id)) if not user or user.is_deleted: raise HTTPException(status_code=404,detail="User not found") # Mail clients, link scanners and the back button all replay this link. if row.is_used: if row.confirmed_at and user.is_active: return serialize_confirmation_result(user,already_confirmed=True) raise HTTPException(status_code=400,detail="This confirmation link is no longer valid. Request a new one.") if row.expires_at<=now_utc(): raise HTTPException(status_code=400,detail="Confirmation link has expired. Request a new one.") if user.is_active: await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) return serialize_confirmation_result(user,already_confirmed=True) updated=await Users.update_user(self.session,str(user.id),{"is_active":True}) await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) return serialize_confirmation_result(updated) async def resend(self,email): user=await Users.get_user_by_email(self.session,email) if not user or user.is_deleted: raise HTTPException(status_code=404,detail="No account found for this email") if user.is_active: raise HTTPException(status_code=400,detail="This account is already confirmed") active=await EmailConfirmationTokens.get_active_token_by_user(self.session,str(user.id)) if active: age=(now_utc()-active.created_at).total_seconds() if agenow_utc(): raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email") return await self.send_confirmation(user)