Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions backend/api/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions backend/api/app/models/weather_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
72 changes: 71 additions & 1 deletion backend/api/app/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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)})

Expand All @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions backend/api/app/services/email.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions backend/api/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
pythonpath = .
3 changes: 3 additions & 0 deletions backend/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions backend/api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
80 changes: 80 additions & 0 deletions backend/api/tests/test_email_verification.py
Original file line number Diff line number Diff line change
@@ -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
Loading