From 1379f177ea033ae0ad2e7f8e5d14610796107e20 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:43:42 +0000 Subject: [PATCH] Implement email verification flow for new users - Replace auto-verification with `is_verified=False` by default. - Add `verification_token` and `verification_token_expires` to `User` model. - Create `EmailService` with support for SMTP and console (mock) backends. - Update `register` endpoint to generate token and send email. - Add `/verify-email` and `/resend-verification` endpoints. - Update `update_db.py` to add new columns to `users` table. - Fix `WeatherHistory` model to be compatible with SQLite (use `Uuid` instead of `postgresql.UUID`). - Add tests for email verification flow using in-memory SQLite. - Update `requirements.txt` with necessary dependencies (`aiosqlite`, `email-validator`, pinned `bcrypt`). Co-authored-by: singhaditya21 <53948039+singhaditya21@users.noreply.github.com> --- backend/api/app/config.py | 8 ++ backend/api/app/models/user.py | 4 + backend/api/app/models/weather_history.py | 5 +- backend/api/app/routers/users.py | 72 ++++++++++++++- backend/api/app/services/email.py | 96 ++++++++++++++++++++ backend/api/pytest.ini | 3 + backend/api/requirements.txt | 3 + backend/api/tests/conftest.py | 53 +++++++++++ backend/api/tests/test_email_verification.py | 80 ++++++++++++++++ backend/api/update_db.py | 14 +++ 10 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 backend/api/app/services/email.py create mode 100644 backend/api/pytest.ini create mode 100644 backend/api/tests/conftest.py create mode 100644 backend/api/tests/test_email_verification.py diff --git a/backend/api/app/config.py b/backend/api/app/config.py index bab5e44..d4fe07c 100644 --- a/backend/api/app/config.py +++ b/backend/api/app/config.py @@ -47,6 +47,14 @@ class Settings(BaseSettings): # Feature flags ENABLE_AI_INSIGHTS: bool = True + # Email + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + EMAIL_FROM: str = "noreply@climaai.com" + EMAIL_BACKEND: str = "console" # console, smtp + class Config: env_file = ".env" case_sensitive = True diff --git a/backend/api/app/models/user.py b/backend/api/app/models/user.py index 899c9fc..ce3d585 100644 --- a/backend/api/app/models/user.py +++ b/backend/api/app/models/user.py @@ -19,6 +19,10 @@ class User(Base): is_active = Column(Boolean, default=True, nullable=False) is_verified = Column(Boolean, default=False, nullable=False) + # Verification + verification_token = Column(String(100), nullable=True, index=True) + verification_token_expires = Column(DateTime(timezone=True), nullable=True) + # Password Reset reset_token = Column(String(100), nullable=True, index=True) reset_token_expires = Column(DateTime(timezone=True), nullable=True) diff --git a/backend/api/app/models/weather_history.py b/backend/api/app/models/weather_history.py index f0cb02d..27e6525 100644 --- a/backend/api/app/models/weather_history.py +++ b/backend/api/app/models/weather_history.py @@ -2,15 +2,14 @@ Weather history model for tracking historical data. Required for trend-based health indices (e.g., Migraine pressure trends). """ -from sqlalchemy import Column, Float, String, Index -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import Column, Float, String, Index, Uuid from ..database import Base import uuid class WeatherHistory(Base): __tablename__ = "weather_history" - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) latitude = Column(Float, nullable=False) longitude = Column(Float, nullable=False) diff --git a/backend/api/app/routers/users.py b/backend/api/app/routers/users.py index 7e3e4eb..813edf8 100644 --- a/backend/api/app/routers/users.py +++ b/backend/api/app/routers/users.py @@ -8,6 +8,7 @@ from ..models import User from ..schemas.user import UserCreate, UserLogin, UserUpdate, UserResponse, TokenResponse, ForgotPasswordRequest from ..services.auth import hash_password, verify_password, create_access_token, get_current_user +from ..services.email import email_service import uuid from datetime import datetime, timedelta @@ -27,6 +28,9 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): detail="Email already registered" ) + # Generate verification token + verification_token = str(uuid.uuid4()) + # Create user user = User( email=user_data.email, @@ -35,13 +39,22 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): platform=user_data.platform, device_token=user_data.device_token, is_active=True, - is_verified=True, # Auto-verify for now, can add email verification later + is_verified=False, + verification_token=verification_token, + verification_token_expires=datetime.utcnow() + timedelta(hours=24) ) db.add(user) await db.commit() await db.refresh(user) + # Send verification email + try: + await email_service.send_verification_email(user.email, verification_token) + except Exception: + # Log error but don't fail registration + pass + # Create access token access_token = create_access_token(data={"sub": str(user.id)}) @@ -51,6 +64,63 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): ) +@router.get("/verify-email", status_code=status.HTTP_200_OK) +async def verify_email(token: str, db: AsyncSession = Depends(get_db)): + """Verify email address with token.""" + result = await db.execute(select(User).where(User.verification_token == token)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid verification token" + ) + + # Check expiry + # Convert DB timestamp to naive UTC for comparison if it's aware + expiry = user.verification_token_expires + if expiry and expiry.tzinfo: + expiry = expiry.replace(tzinfo=None) + + if expiry and expiry < datetime.utcnow(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verification token expired" + ) + + user.is_verified = True + user.verification_token = None + user.verification_token_expires = None + + await db.commit() + + return {"message": "Email verified successfully"} + + +@router.post("/resend-verification", status_code=status.HTTP_200_OK) +async def resend_verification( + request: ForgotPasswordRequest, + db: AsyncSession = Depends(get_db) +): + """Resend verification email.""" + result = await db.execute(select(User).where(User.email == request.email)) + user = result.scalar_one_or_none() + + if user and not user.is_verified: + token = str(uuid.uuid4()) + user.verification_token = token + user.verification_token_expires = datetime.utcnow() + timedelta(hours=24) + + await db.commit() + + try: + await email_service.send_verification_email(user.email, token) + except Exception: + pass + + return {"message": "If the account exists and is unverified, a verification email has been sent."} + + @router.post("/forgot-password", status_code=status.HTTP_200_OK) async def forgot_password( request: ForgotPasswordRequest, diff --git a/backend/api/app/services/email.py b/backend/api/app/services/email.py new file mode 100644 index 0000000..51baeba --- /dev/null +++ b/backend/api/app/services/email.py @@ -0,0 +1,96 @@ +""" +Email service for sending notifications and verification emails. +""" +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from ..config import get_settings +import asyncio +import logging + +logger = logging.getLogger(__name__) + +class EmailService: + def __init__(self): + self.settings = get_settings() + + def send_email(self, to_email: str, subject: str, body: str): + """ + Send an email using the configured backend. + """ + if self.settings.EMAIL_BACKEND == "console": + print(f"📧 [Mock Email] To: {to_email}") + print(f" Subject: {subject}") + print(f" Body: {body}") + return + + if self.settings.EMAIL_BACKEND == "smtp": + try: + msg = MIMEMultipart() + msg["From"] = self.settings.EMAIL_FROM + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + with smtplib.SMTP(self.settings.SMTP_HOST, self.settings.SMTP_PORT) as server: + server.starttls() + if self.settings.SMTP_USER and self.settings.SMTP_PASSWORD: + server.login(self.settings.SMTP_USER, self.settings.SMTP_PASSWORD) + server.send_message(msg) + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + # We log but might not want to raise to prevent API failure on email error + # depending on criticality. For verification, it's critical. + raise + + async def send_email_async(self, to_email: str, subject: str, body: str): + """ + Send an email asynchronously. + """ + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self.send_email, to_email, subject, body) + + async def send_verification_email(self, to_email: str, token: str): + """ + Send verification email with token. + """ + subject = "Verify your email for ClimaAI" + # In a real app, this would be a link to the frontend which then calls the API + # Or a link to the API directly if handling verification server-side only + # Assuming we want to link to a web verification page or deep link + verification_link = f"https://climaai.app/verify-email?token={token}" + + body = f"""Welcome to ClimaAI! + +Please verify your email address by clicking the link below: + +{verification_link} + +If you did not sign up for ClimaAI, please ignore this email. + +Best regards, +The ClimaAI Team +""" + await self.send_email_async(to_email, subject, body) + + async def send_password_reset_email(self, to_email: str, token: str): + """ + Send password reset email. + """ + subject = "Reset your password for ClimaAI" + reset_link = f"https://climaai.app/reset-password?token={token}" + + body = f"""Hello, + +We received a request to reset your password. Click the link below to choose a new password: + +{reset_link} + +If you did not request a password reset, please ignore this email. + +Best regards, +The ClimaAI Team +""" + await self.send_email_async(to_email, subject, body) + +email_service = EmailService() diff --git a/backend/api/pytest.ini b/backend/api/pytest.ini new file mode 100644 index 0000000..82bc8d1 --- /dev/null +++ b/backend/api/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +pythonpath = . diff --git a/backend/api/requirements.txt b/backend/api/requirements.txt index 27ef777..28d6b13 100644 --- a/backend/api/requirements.txt +++ b/backend/api/requirements.txt @@ -31,3 +31,6 @@ python-dotenv==1.0.0 # Utilities python-dateutil==2.8.2 PyJWT==2.8.0 +aiosqlite==0.19.0 +bcrypt==3.2.2 +email-validator==2.3.0 diff --git a/backend/api/tests/conftest.py b/backend/api/tests/conftest.py new file mode 100644 index 0000000..a02e68a --- /dev/null +++ b/backend/api/tests/conftest.py @@ -0,0 +1,53 @@ +import pytest +import asyncio +import os +import sys + +# Set environment variables for testing BEFORE importing app modules +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["EMAIL_BACKEND"] = "console" +os.environ["REDIS_URL"] = "redis://mock-redis:6379/0" + +# Add backend/api to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from app.database import Base, get_db, engine +from app.main import app + +@pytest.fixture(scope="session") +def event_loop(): + """Create an instance of the default event loop for each test case.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + +@pytest.fixture(scope="function") +async def db_engine(): + # Ensure tables are created + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + # Cleanup + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + +@pytest.fixture +async def db_session(db_engine): + async_session = async_sessionmaker(db_engine, expire_on_commit=False, class_=AsyncSession) + async with async_session() as session: + yield session + await session.rollback() + +@pytest.fixture +async def client(db_session): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + # Using ASGITransport + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + app.dependency_overrides.clear() diff --git a/backend/api/tests/test_email_verification.py b/backend/api/tests/test_email_verification.py new file mode 100644 index 0000000..a19a7fa --- /dev/null +++ b/backend/api/tests/test_email_verification.py @@ -0,0 +1,80 @@ +import pytest +from app.models import User +from sqlalchemy import select + +@pytest.mark.asyncio +async def test_registration_creates_unverified_user(client, db_session): + # Register + response = await client.post("/api/auth/register", json={ + "email": "test@example.com", + "password": "Password123!", + "full_name": "Test User", + "platform": "ios" + }) + assert response.status_code == 201 + data = response.json() + assert "access_token" in data + assert data["user"]["is_verified"] is False + + # Check DB + result = await db_session.execute(select(User).where(User.email == "test@example.com")) + user = result.scalar_one() + assert user.is_verified is False + assert user.verification_token is not None + assert user.verification_token_expires is not None + +@pytest.mark.asyncio +async def test_verify_email(client, db_session): + # Register first + await client.post("/api/auth/register", json={ + "email": "verify@example.com", + "password": "Password123!", + "full_name": "Verify User", + "platform": "android" + }) + + # Get token from DB + result = await db_session.execute(select(User).where(User.email == "verify@example.com")) + user = result.scalar_one() + token = user.verification_token + + # Verify + response = await client.get(f"/api/auth/verify-email?token={token}") + assert response.status_code == 200 + assert response.json()["message"] == "Email verified successfully" + + # Check DB - Need to expire session or refresh user to see changes committed by API + await db_session.refresh(user) + assert user.is_verified is True + assert user.verification_token is None + +@pytest.mark.asyncio +async def test_verify_email_invalid_token(client): + response = await client.get("/api/auth/verify-email?token=invalid-token") + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid verification token" + +@pytest.mark.asyncio +async def test_resend_verification(client, db_session): + # Register + await client.post("/api/auth/register", json={ + "email": "resend@example.com", + "password": "Password123!", + "full_name": "Resend User", + "platform": "ios" + }) + + # Get old token + result = await db_session.execute(select(User).where(User.email == "resend@example.com")) + user = result.scalar_one() + old_token = user.verification_token + + # Resend + response = await client.post("/api/auth/resend-verification", json={ + "email": "resend@example.com" + }) + assert response.status_code == 200 + + # Check DB for new token + await db_session.refresh(user) + assert user.verification_token != old_token diff --git a/backend/api/update_db.py b/backend/api/update_db.py index e4b00d9..7e9db3d 100644 --- a/backend/api/update_db.py +++ b/backend/api/update_db.py @@ -29,6 +29,20 @@ async def update_schema(): except Exception as e: print(f"Error adding reset_token_expires: {e}") + # Add verification_token + try: + await conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token VARCHAR(100)")) + print("Added/Checked verification_token column") + except Exception as e: + print(f"Error adding verification_token: {e}") + + # Add verification_token_expires + try: + await conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS verification_token_expires TIMESTAMP WITH TIME ZONE")) + print("Added/Checked verification_token_expires column") + except Exception as e: + print(f"Error adding verification_token_expires: {e}") + print("Schema update complete") except Exception as e: print(f"Connection error: {e}")