diff --git a/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py b/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py
new file mode 100644
index 0000000..832a859
--- /dev/null
+++ b/alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py
@@ -0,0 +1,44 @@
+"""add_password_reset_tokens_table
+
+Revision ID: 3ead82c29db6
+Revises: 21fa4ae8b1cc
+Create Date: 2026-07-06 15:40:14.276063
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '3ead82c29db6'
+down_revision: Union[str, Sequence[str], None] = '21fa4ae8b1cc'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('password_reset_tokens',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('token_hash', sa.String(length=64), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('token_hash')
+ )
+ op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens')
+ op.drop_table('password_reset_tokens')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py b/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py
new file mode 100644
index 0000000..e02ffcf
--- /dev/null
+++ b/alembic/versions/526b088ddfed_add_email_verification_tokens_table.py
@@ -0,0 +1,44 @@
+"""add_email_verification_tokens_table
+
+Revision ID: 526b088ddfed
+Revises: 3ead82c29db6
+Create Date: 2026-07-06 18:13:12.061547
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '526b088ddfed'
+down_revision: Union[str, Sequence[str], None] = '3ead82c29db6'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('email_verification_tokens',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('token_hash', sa.String(length=64), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('token_hash')
+ )
+ op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_email_verification_tokens_user_id'), table_name='email_verification_tokens')
+ op.drop_table('email_verification_tokens')
+ # ### end Alembic commands ###
diff --git a/docker-compose.yml b/docker-compose.yml
index 4eac8c6..d4722b0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,14 +11,22 @@ services:
- APP_VERSION=0.1.0
- APP_ENV=development
- DEBUG=true
+
- SECRET_KEY=${SECRET_KEY}
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=30
+
- DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@db:5432/trading_db
+
- AI_PROVIDER=openai
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- AI_MODEL=gpt-4o
+
- ALLOWED_ORIGINS=["http://localhost:3000","http://localhost:8000"]
+
+ - RESEND_API_KEY=${RESEND_API_KEY}
+ - EMAIL_FROM=${EMAIL_FROM}
+ - FRONTEND_URL=${FRONTEND_URL}
depends_on:
db:
condition: service_healthy
diff --git a/pyproject.toml b/pyproject.toml
index 8ea1a12..667e8bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
"httpx>=0.28.1",
"pydantic-settings>=2.14.2",
"pyjwt[crypto]>=2.13.0",
+ "resend[async]>=2.32.2",
"sqlalchemy>=2.0.51",
]
diff --git a/src/ai_trading_discipline_copilot/core/config.py b/src/ai_trading_discipline_copilot/core/config.py
index c867cc4..238b37d 100644
--- a/src/ai_trading_discipline_copilot/core/config.py
+++ b/src/ai_trading_discipline_copilot/core/config.py
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
refresh_token_expire_days: int = 7
bcrypt_rounds: int = 12
cookie_name: str = "refresh_token"
+
# Defaults True so cookies always require HTTPS outside local dev.
# Override with COOKIE_SECURE=false in .env for local development only.
cookie_secure: bool = True
@@ -55,8 +56,16 @@ class Settings(BaseSettings):
openai_api_key: SecretStr = SecretStr("")
ai_model: str = "gpt-4o"
+ # Email
+ resend_api_key: SecretStr
+ email_from: str
+ frontend_url: str
+
# CORS
- allowed_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"]
+ allowed_origins: list[str] = [
+ "http://localhost:3000",
+ "http://localhost:8000",
+ ]
@field_validator("secret_key")
@classmethod
@@ -69,24 +78,31 @@ def secret_key_min_length(cls, v: SecretStr) -> SecretStr:
@classmethod
def validate_database_url(cls, v: SecretStr) -> SecretStr:
url_str = v.get_secret_value()
+
if not url_str.startswith("postgresql+asyncpg://"):
raise ValueError("database_url must start with 'postgresql+asyncpg://'")
+
from pydantic import TypeAdapter
try:
TypeAdapter(PostgresDsn).validate_python(url_str)
except Exception as e:
raise ValueError(f"Invalid database URL format: {e}") from None
+
return v
@field_validator("allowed_origins")
@classmethod
- def validate_allowed_origins(cls, v: list[str]) -> list[str]:
+ def validate_allowed_origins(
+ cls,
+ v: list[str],
+ ) -> list[str]:
if "*" in v:
raise ValueError(
"Wildcard '*' is not allowed in allowed_origins "
"when credentials are enabled"
)
+
return v
diff --git a/src/ai_trading_discipline_copilot/models/__init__.py b/src/ai_trading_discipline_copilot/models/__init__.py
index c5bb8ea..2f6a2ff 100644
--- a/src/ai_trading_discipline_copilot/models/__init__.py
+++ b/src/ai_trading_discipline_copilot/models/__init__.py
@@ -1,6 +1,13 @@
# SQLAlchemy ORM models
# Contains: base, user, trade, journal, psychology
+from .email_verification_token import EmailVerificationToken
+from .password_reset_token import PasswordResetToken
from .refresh_token import RefreshToken
from .user import User
-__all__ = ["User", "RefreshToken"]
+__all__ = [
+ "User",
+ "RefreshToken",
+ "PasswordResetToken",
+ "EmailVerificationToken",
+]
diff --git a/src/ai_trading_discipline_copilot/models/email_verification_token.py b/src/ai_trading_discipline_copilot/models/email_verification_token.py
new file mode 100644
index 0000000..b027ec6
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/models/email_verification_token.py
@@ -0,0 +1,62 @@
+"""Email verification token model."""
+
+from __future__ import annotations
+
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+from sqlalchemy import DateTime, ForeignKey, String
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from ai_trading_discipline_copilot.core.database import Base
+
+if TYPE_CHECKING:
+ from .user import User
+
+
+class EmailVerificationToken(Base):
+ """Stores one-time email verification tokens."""
+
+ __tablename__ = "email_verification_tokens"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ token_hash: Mapped[str] = mapped_column(
+ String(64),
+ nullable=False,
+ unique=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC),
+ nullable=False,
+ )
+
+ expires_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC) + timedelta(hours=24),
+ nullable=False,
+ )
+
+ used_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ user: Mapped[User] = relationship(
+ back_populates="email_verification_tokens",
+ )
diff --git a/src/ai_trading_discipline_copilot/models/password_reset_token.py b/src/ai_trading_discipline_copilot/models/password_reset_token.py
new file mode 100644
index 0000000..c248e06
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/models/password_reset_token.py
@@ -0,0 +1,62 @@
+"""Password reset token model."""
+
+from __future__ import annotations
+
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+from sqlalchemy import DateTime, ForeignKey, String
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from ai_trading_discipline_copilot.core.database import Base
+
+if TYPE_CHECKING:
+ from .user import User
+
+
+class PasswordResetToken(Base):
+ """Stores one-time password reset tokens."""
+
+ __tablename__ = "password_reset_tokens"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ primary_key=True,
+ default=uuid.uuid4,
+ )
+
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+
+ token_hash: Mapped[str] = mapped_column(
+ String(64),
+ nullable=False,
+ unique=True,
+ )
+
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC),
+ nullable=False,
+ )
+
+ expires_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC) + timedelta(minutes=15),
+ nullable=False,
+ )
+
+ used_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ user: Mapped[User] = relationship(
+ back_populates="password_reset_tokens",
+ )
diff --git a/src/ai_trading_discipline_copilot/models/user.py b/src/ai_trading_discipline_copilot/models/user.py
index 37b5b77..a75b752 100644
--- a/src/ai_trading_discipline_copilot/models/user.py
+++ b/src/ai_trading_discipline_copilot/models/user.py
@@ -13,10 +13,11 @@
relationship,
)
-from ai_trading_discipline_copilot.core.database import Base
-
if TYPE_CHECKING:
+ from .email_verification_token import EmailVerificationToken
+ from .password_reset_token import PasswordResetToken
from .refresh_token import RefreshToken
+from ai_trading_discipline_copilot.core.database import Base
class UserRole(enum.StrEnum):
@@ -60,7 +61,14 @@ class User(Base):
default=UserRole.USER,
nullable=False,
)
-
+ password_reset_tokens: Mapped[list[PasswordResetToken]] = relationship(
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+ email_verification_tokens: Mapped[list[EmailVerificationToken]] = relationship(
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
diff --git a/src/ai_trading_discipline_copilot/routers/auth.py b/src/ai_trading_discipline_copilot/routers/auth.py
index 4df755f..a8ffcee 100644
--- a/src/ai_trading_discipline_copilot/routers/auth.py
+++ b/src/ai_trading_discipline_copilot/routers/auth.py
@@ -1,3 +1,4 @@
+import logging
import uuid
from typing import TYPE_CHECKING, Annotated
@@ -10,6 +11,7 @@
status,
)
from fastapi.security import OAuth2PasswordRequestForm
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_trading_discipline_copilot.core.config import get_settings
@@ -18,10 +20,24 @@
get_db,
)
from ai_trading_discipline_copilot.core.exceptions import (
+ ForbiddenException,
NotFoundException,
UnauthorizedException,
)
from ai_trading_discipline_copilot.core.security import decode_refresh_token
+from ai_trading_discipline_copilot.models.user import User
+from ai_trading_discipline_copilot.schemas.email_verification import (
+ ResendVerificationRequest,
+ ResendVerificationResponse,
+ VerifyEmailRequest,
+ VerifyEmailResponse,
+)
+from ai_trading_discipline_copilot.schemas.password_reset import (
+ ForgotPasswordRequest,
+ ForgotPasswordResponse,
+ ResetPasswordRequest,
+ ResetPasswordResponse,
+)
from ai_trading_discipline_copilot.schemas.user import (
Token,
UserCreate,
@@ -29,15 +45,24 @@
UserSessionResponse,
)
from ai_trading_discipline_copilot.services.auth_service import AuthService
+from ai_trading_discipline_copilot.services.email_service import EmailService
+from ai_trading_discipline_copilot.services.email_verification_service import (
+ EmailVerificationService,
+)
+from ai_trading_discipline_copilot.services.password_reset_service import (
+ PasswordResetService,
+)
from ai_trading_discipline_copilot.services.refresh_token_service import (
RefreshTokenService,
)
from ai_trading_discipline_copilot.services.user_service import UserService
if TYPE_CHECKING:
- from ai_trading_discipline_copilot.models.user import User
+ pass
settings = get_settings()
+_REFRESH_COOKIE_NAME = settings.cookie_name
+logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/auth",
@@ -80,11 +105,23 @@ async def run_cleanup_task() -> None:
)
async def register(
user_data: UserCreate,
+ background_tasks: BackgroundTasks,
db: Annotated[AsyncSession, Depends(get_db)],
) -> UserResponse:
"""Register a new user."""
user = await UserService.register_user(db, user_data)
+
+ plain_token = await EmailVerificationService.create_verification_token(db, user)
+ verification_url = f"{settings.frontend_url}/verify-email?token={plain_token}"
+
+ background_tasks.add_task(
+ send_verification_email_task,
+ user_id=user.id,
+ email=user.email,
+ verification_url=verification_url,
+ )
+
return UserResponse.model_validate(user)
@@ -133,7 +170,7 @@ async def refresh(
) -> Token:
"""Rotate the refresh token and issue a new access token."""
- refresh_token = request.cookies.get(settings.cookie_name)
+ refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME)
if refresh_token is None:
raise UnauthorizedException("Missing refresh token")
@@ -185,11 +222,9 @@ async def logout(
response: Response,
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
- """Log out the current session by revoking the refresh token
- and deleting the cookie.
- """
+ """Log out the current session by revoking the refresh token and deleting the cookie.""" # noqa: E501
- refresh_token = request.cookies.get(settings.cookie_name)
+ refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME)
await AuthService.logout(
response=response,
db=db,
@@ -226,7 +261,7 @@ async def get_sessions(
) -> list[UserSessionResponse]:
"""Get all active sessions for the authenticated user."""
- refresh_token = request.cookies.get(settings.cookie_name)
+ refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME)
current_jti = None
if refresh_token:
payload = decode_refresh_token(refresh_token)
@@ -265,7 +300,7 @@ async def revoke_session(
) -> None:
"""Revoke a specific session for the user."""
- refresh_token = request.cookies.get(settings.cookie_name)
+ refresh_token = request.cookies.get(_REFRESH_COOKIE_NAME)
session = await RefreshTokenService.get_by_id(db=db, session_id=session_id)
if not session or session.user_id != current_user.id:
raise NotFoundException("Session not found")
@@ -277,3 +312,304 @@ async def revoke_session(
payload = decode_refresh_token(refresh_token)
if payload and payload.get("jti") == session.jti:
AuthService.delete_refresh_cookie(response)
+
+
+@router.post(
+ "/cleanup",
+ status_code=status.HTTP_200_OK,
+)
+async def cleanup_sessions(
+ current_user: Annotated["User", Depends(get_current_user)],
+ db: Annotated[AsyncSession, Depends(get_db)],
+) -> dict[str, int]:
+ """Clean up expired sessions from the database (Admin only)."""
+ from ai_trading_discipline_copilot.models.user import UserRole
+
+ if current_user.role != UserRole.ADMIN:
+ raise ForbiddenException("Only admins can perform session cleanup")
+
+ deleted_count = await RefreshTokenService.cleanup_expired_sessions(db=db)
+ return {"deleted_sessions": deleted_count}
+
+
+async def send_verification_email_task(
+ user_id: uuid.UUID,
+ email: str,
+ verification_url: str,
+) -> None:
+ """Send email verification link asynchronously in the background.
+
+ Args:
+ user_id: The ID of the user.
+ email: The recipient's email address.
+ verification_url: The verification link.
+ """
+ try:
+ await EmailService.send_verification_email(
+ to=email,
+ verification_url=verification_url,
+ )
+ except Exception as e:
+ logger.exception(
+ "Failed to send email [type=verification] to user_id=%s, email=%s: %s",
+ user_id,
+ email,
+ e,
+ )
+
+
+async def send_reset_email_task(
+ user_id: uuid.UUID,
+ email: str,
+ reset_url: str,
+ app_name: str,
+) -> None:
+ """Send a password reset email asynchronously in the background.
+
+ Args:
+ user_id: The ID of the user.
+ email: The recipient's email address.
+ reset_url: The plain-text password reset URL.
+ app_name: The application name to display.
+ """
+ try:
+ import html
+ from datetime import UTC, datetime
+
+ escaped_app_name = html.escape(app_name)
+ escaped_reset_url = html.escape(reset_url)
+ current_year = datetime.now(UTC).year
+
+ html_content = f"""
+
+
+
+
+ Reset your password
+
+
+
+
+
+
+
Hello,
+
We received a request to reset the password for your account.
+
Click the button below to set a new password:
+
+
This password reset link is only valid for 15 minutes.
+
If you did not request a password reset, you can safely ignore this email.
+
+ If you're having trouble clicking the button, copy and paste the URL below:
+ {escaped_reset_url}
+
+
+
+
+
+"""
+
+ await EmailService.send_email(
+ to=email,
+ subject="Reset your password",
+ html=html_content,
+ )
+ except Exception as e:
+ logger.exception(
+ "Failed to send email [type=password_reset] to user_id=%s, email=%s: %s",
+ user_id,
+ email,
+ e,
+ )
+
+
+@router.post(
+ "/forgot-password",
+ response_model=ForgotPasswordResponse,
+ status_code=status.HTTP_200_OK,
+)
+async def forgot_password(
+ request_data: ForgotPasswordRequest,
+ background_tasks: BackgroundTasks,
+ db: Annotated[AsyncSession, Depends(get_db)],
+) -> ForgotPasswordResponse:
+ """Request a password reset link.
+
+ Generates a password reset token if the email exists, builds a reset link,
+ and sends a reset email in the background. Always returns a generic success
+ message to prevent account enumeration.
+ """
+ result = await db.execute(
+ select(User).where(User.email == request_data.email)
+ )
+ user = result.scalar_one_or_none()
+
+ if user:
+ plain_token = await PasswordResetService.create_reset_token(db, user)
+ reset_url = f"{settings.frontend_url}/reset-password?token={plain_token}"
+ background_tasks.add_task(
+ send_reset_email_task,
+ user_id=user.id,
+ email=user.email,
+ reset_url=reset_url,
+ app_name=settings.app_name,
+ )
+
+ return ForgotPasswordResponse(
+ message=(
+ "If an account with that email exists, "
+ "a password reset link has been sent."
+ )
+ )
+
+
+@router.post(
+ "/reset-password",
+ response_model=ResetPasswordResponse,
+ status_code=status.HTTP_200_OK,
+)
+async def reset_password(
+ request_data: ResetPasswordRequest,
+ db: Annotated[AsyncSession, Depends(get_db)],
+) -> ResetPasswordResponse:
+ """Reset password using a valid reset token.
+
+ Verifies the token, updates the password, and revokes all active sessions.
+ """
+ try:
+ await PasswordResetService.reset_password(
+ db=db,
+ token=request_data.token,
+ new_password=request_data.new_password,
+ )
+ except UnauthorizedException as err:
+ raise UnauthorizedException(
+ "Invalid or expired password reset token"
+ ) from err
+
+ return ResetPasswordResponse(
+ message="Password reset successful."
+ )
+
+
+@router.post(
+ "/verify-email",
+ response_model=VerifyEmailResponse,
+ status_code=status.HTTP_200_OK,
+)
+async def verify_email(
+ request_data: VerifyEmailRequest,
+ db: Annotated[AsyncSession, Depends(get_db)],
+) -> VerifyEmailResponse:
+ """Verify a user's email using a verification token."""
+ try:
+ await EmailVerificationService.verify_email(db, request_data.token)
+ except UnauthorizedException as err:
+ raise UnauthorizedException(
+ "Invalid or expired email verification token"
+ ) from err
+
+ return VerifyEmailResponse(
+ message="Email verified successfully."
+ )
+
+
+@router.post(
+ "/resend-verification",
+ response_model=ResendVerificationResponse,
+ status_code=status.HTTP_200_OK,
+)
+async def resend_verification(
+ request_data: ResendVerificationRequest,
+ background_tasks: BackgroundTasks,
+ db: Annotated[AsyncSession, Depends(get_db)],
+) -> ResendVerificationResponse:
+ """Resend email verification link.
+
+ Always returns a generic success response to prevent account enumeration.
+ """
+ result = await db.execute(
+ select(User).where(User.email == request_data.email)
+ )
+ user = result.scalar_one_or_none()
+
+ if user and not user.is_verified:
+ plain_token = await EmailVerificationService.create_verification_token(
+ db, user
+ )
+ verification_url = (
+ f"{settings.frontend_url}/verify-email?token={plain_token}"
+ )
+ background_tasks.add_task(
+ send_verification_email_task,
+ user_id=user.id,
+ email=user.email,
+ verification_url=verification_url,
+ )
+
+ return ResendVerificationResponse(
+ message="Verification email sent."
+ )
+
+
diff --git a/src/ai_trading_discipline_copilot/schemas/__init__.py b/src/ai_trading_discipline_copilot/schemas/__init__.py
index 3f8b224..f31e835 100644
--- a/src/ai_trading_discipline_copilot/schemas/__init__.py
+++ b/src/ai_trading_discipline_copilot/schemas/__init__.py
@@ -1,8 +1,28 @@
# Pydantic request/response schemas
# Contains: user, trade, journal, psychology
+from .email_verification import (
+ ResendVerificationRequest,
+ ResendVerificationResponse,
+ VerifyEmailRequest,
+ VerifyEmailResponse,
+)
+from .password_reset import (
+ ForgotPasswordRequest,
+ ForgotPasswordResponse,
+ ResetPasswordRequest,
+ ResetPasswordResponse,
+)
from .user import UserCreate, UserResponse
__all__ = [
"UserCreate",
"UserResponse",
+ "ForgotPasswordRequest",
+ "ForgotPasswordResponse",
+ "ResetPasswordRequest",
+ "ResetPasswordResponse",
+ "VerifyEmailRequest",
+ "VerifyEmailResponse",
+ "ResendVerificationRequest",
+ "ResendVerificationResponse",
]
diff --git a/src/ai_trading_discipline_copilot/schemas/email_verification.py b/src/ai_trading_discipline_copilot/schemas/email_verification.py
new file mode 100644
index 0000000..f5af8e1
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/schemas/email_verification.py
@@ -0,0 +1,27 @@
+"""Schemas for email verification."""
+
+from pydantic import BaseModel, EmailStr
+
+
+class VerifyEmailRequest(BaseModel):
+ """Request to verify email."""
+
+ token: str
+
+
+class VerifyEmailResponse(BaseModel):
+ """Response for email verification."""
+
+ message: str
+
+
+class ResendVerificationRequest(BaseModel):
+ """Request to resend verification email."""
+
+ email: EmailStr
+
+
+class ResendVerificationResponse(BaseModel):
+ """Response for resending verification email."""
+
+ message: str
diff --git a/src/ai_trading_discipline_copilot/schemas/password_reset.py b/src/ai_trading_discipline_copilot/schemas/password_reset.py
new file mode 100644
index 0000000..5d681d2
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/schemas/password_reset.py
@@ -0,0 +1,29 @@
+"""Schemas for password reset."""
+
+from pydantic import BaseModel, EmailStr, Field
+
+
+class ForgotPasswordRequest(BaseModel):
+ """Request to initiate password reset."""
+
+ email: EmailStr
+
+
+class ResetPasswordRequest(BaseModel):
+ """Request to reset password."""
+
+ token: str = Field(min_length=32)
+ new_password: str = Field(min_length=8, max_length=72)
+
+
+class ForgotPasswordResponse(BaseModel):
+ """Response to forgot password request."""
+
+ message: str
+
+
+class ResetPasswordResponse(BaseModel):
+ """Response to reset password execution."""
+
+ message: str
+
diff --git a/src/ai_trading_discipline_copilot/services/__init__.py b/src/ai_trading_discipline_copilot/services/__init__.py
index e266609..3d1cdda 100644
--- a/src/ai_trading_discipline_copilot/services/__init__.py
+++ b/src/ai_trading_discipline_copilot/services/__init__.py
@@ -1,5 +1,12 @@
# Business logic services
# Contains: auth, trade, journal, psychology, ai_coach
+from .email_verification_service import EmailVerificationService
+from .password_reset_service import PasswordResetService
from .user_service import UserService
-__all__ = ["UserService"]
+__all__ = [
+ "UserService",
+ "PasswordResetService",
+ "EmailVerificationService",
+]
+
diff --git a/src/ai_trading_discipline_copilot/services/auth_service.py b/src/ai_trading_discipline_copilot/services/auth_service.py
index c3ede35..208df35 100644
--- a/src/ai_trading_discipline_copilot/services/auth_service.py
+++ b/src/ai_trading_discipline_copilot/services/auth_service.py
@@ -6,7 +6,10 @@
from sqlalchemy.ext.asyncio import AsyncSession
from ai_trading_discipline_copilot.core.config import get_settings
-from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException
+from ai_trading_discipline_copilot.core.exceptions import (
+ ForbiddenException,
+ UnauthorizedException,
+)
from ai_trading_discipline_copilot.core.security import (
create_access_token,
create_refresh_token,
@@ -131,6 +134,12 @@ async def login(
password=password,
)
+ if not user.is_verified:
+ logger.warning(
+ "Blocked login attempt for unverified user: '%s'", user.username
+ )
+ raise ForbiddenException("Please verify your email before logging in.")
+
access_token, refresh_token, refresh_jti = AuthService._create_tokens(
user,
)
diff --git a/src/ai_trading_discipline_copilot/services/email_service.py b/src/ai_trading_discipline_copilot/services/email_service.py
new file mode 100644
index 0000000..5987902
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/services/email_service.py
@@ -0,0 +1,145 @@
+"""Email service powered by Resend."""
+
+from __future__ import annotations
+
+import resend
+
+from ai_trading_discipline_copilot.core.config import get_settings
+
+settings = get_settings()
+
+resend.api_key = settings.resend_api_key.get_secret_value()
+
+
+class EmailService:
+ """Business logic for sending emails."""
+
+ @staticmethod
+ async def send_email(
+ *,
+ to: str,
+ subject: str,
+ html: str,
+ ) -> None:
+ """Send an email using Resend."""
+
+ await resend.Emails.send_async(
+ {
+ "from": settings.email_from,
+ "to": to,
+ "subject": subject,
+ "html": html,
+ }
+ )
+
+ @staticmethod
+ async def send_verification_email(
+ *,
+ to: str,
+ verification_url: str,
+ ) -> None:
+ """Send email verification link using Resend."""
+ import html
+ from datetime import UTC, datetime
+
+ escaped_app_name = html.escape(settings.app_name)
+ escaped_verification_url = html.escape(verification_url)
+ current_year = datetime.now(UTC).year
+
+ html_content = f"""
+
+
+
+
+ Verify your email
+
+
+
+
+
+
+
Hello,
+
Thank you for registering.
+
Please click the button below to verify your email:
+
+
This verification link is only valid for 24 hours.
+
If you did not request this, you can safely ignore this email.
+
+ If you're having trouble clicking the button, copy and paste the URL below:
+ {escaped_verification_url}
+
+
+
+
+
+"""
+
+ await EmailService.send_email(
+ to=to,
+ subject="Verify your email",
+ html=html_content,
+ )
diff --git a/src/ai_trading_discipline_copilot/services/email_verification_service.py b/src/ai_trading_discipline_copilot/services/email_verification_service.py
new file mode 100644
index 0000000..8176e14
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/services/email_verification_service.py
@@ -0,0 +1,203 @@
+"""Email verification service business logic."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import secrets
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+from sqlalchemy import delete, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException
+from ai_trading_discipline_copilot.models.email_verification_token import (
+ EmailVerificationToken,
+)
+
+if TYPE_CHECKING:
+ from ai_trading_discipline_copilot.models.user import User
+
+logger = logging.getLogger(__name__)
+
+
+class EmailVerificationService:
+ """Business logic for handling email verifications."""
+
+ @staticmethod
+ def generate_token() -> str:
+ """Generate a secure random token.
+
+ Returns:
+ str: A secure random URL-safe string.
+ """
+ return secrets.token_urlsafe(32)
+
+ @staticmethod
+ def hash_token(token: str) -> str:
+ """Return the SHA-256 hash of a token.
+
+ Args:
+ token: The plain-text token.
+
+ Returns:
+ str: The hex-encoded SHA-256 hash of the token.
+ """
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+ @staticmethod
+ async def create_verification_token(
+ db: AsyncSession,
+ user: User,
+ ) -> str:
+ """Create a new email verification token.
+
+ Generates a token, hashes it, deletes previous unused verification tokens
+ for the user, inserts a new EmailVerificationToken that expires in 24 hours,
+ commits the transaction, and returns the plain-text token.
+
+ Args:
+ db: The database session.
+ user: The user requesting verification.
+
+ Returns:
+ str: The plain-text token.
+ """
+ plain_token = EmailVerificationService.generate_token()
+ token_hash = EmailVerificationService.hash_token(plain_token)
+
+ # Delete previous unused verification tokens for the same user
+ await db.execute(
+ delete(EmailVerificationToken).where(
+ EmailVerificationToken.user_id == user.id,
+ EmailVerificationToken.used_at.is_(None),
+ )
+ )
+
+ # Insert new EmailVerificationToken expiring in 24 hours
+ expires_at = datetime.now(UTC) + timedelta(hours=24)
+ verification_token = EmailVerificationToken(
+ user_id=user.id,
+ token_hash=token_hash,
+ expires_at=expires_at,
+ )
+ db.add(verification_token)
+ await db.commit()
+
+ logger.info(
+ "Created email verification token for user ID: %s expiring at %s",
+ user.id,
+ expires_at,
+ )
+
+ return plain_token
+
+ @staticmethod
+ async def validate_token(
+ db: AsyncSession,
+ token: str,
+ ) -> EmailVerificationToken:
+ """Validate an email verification token.
+
+ Hashes the incoming token, checks the database, validates that the token
+ exists, is not expired, and has not been used yet.
+
+ Args:
+ db: The database session.
+ token: The plain-text verification token.
+
+ Returns:
+ EmailVerificationToken: The validated token model instance with user loaded.
+
+ Raises:
+ UnauthorizedException: If the token is invalid, expired, or already used.
+ """
+ token_hash = EmailVerificationService.hash_token(token)
+ stmt = (
+ select(EmailVerificationToken)
+ .where(EmailVerificationToken.token_hash == token_hash)
+ .options(selectinload(EmailVerificationToken.user))
+ )
+ result = await db.execute(stmt)
+ db_token = result.scalar_one_or_none()
+
+ if db_token is None:
+ logger.warning("Email verification token not found in database.")
+ raise UnauthorizedException("Invalid or expired email verification token")
+
+ if db_token.used_at is not None:
+ logger.warning(
+ "Email verification token already used at %s.", db_token.used_at
+ )
+ raise UnauthorizedException("Invalid or expired email verification token")
+
+ if db_token.expires_at <= datetime.now(UTC):
+ logger.warning(
+ "Email verification token expired at %s.", db_token.expires_at
+ )
+ raise UnauthorizedException("Invalid or expired email verification token")
+
+ return db_token
+
+ @staticmethod
+ async def verify_email(
+ db: AsyncSession,
+ token: str,
+ ) -> None:
+ """Verify the user's email using the provided verification token.
+
+ Validates the token, loads the user, sets is_verified=True on the user,
+ marks the token as used, and commits.
+
+ Args:
+ db: The database session.
+ token: The plain-text verification token.
+
+ Raises:
+ UnauthorizedException: If the token is invalid or expired.
+ """
+ db_token = await EmailVerificationService.validate_token(db, token)
+ user = db_token.user
+
+ # Mark user as verified
+ user.is_verified = True
+
+ # Mark token as used
+ db_token.used_at = datetime.now(UTC)
+
+ await db.commit()
+ logger.info(
+ "Successfully verified email for user ID: %s",
+ user.id,
+ )
+
+ @staticmethod
+ async def cleanup_expired_tokens(
+ db: AsyncSession,
+ ) -> int:
+ """Delete all expired or already-used email verification tokens.
+
+ Args:
+ db: The database session.
+
+ Returns:
+ int: The number of deleted tokens.
+ """
+ from sqlalchemy.engine import CursorResult
+
+ stmt = delete(EmailVerificationToken).where(
+ or_(
+ EmailVerificationToken.expires_at <= datetime.now(UTC),
+ EmailVerificationToken.used_at.is_not(None),
+ )
+ )
+ result: CursorResult[tuple[()]] = await db.execute(stmt) # type: ignore[assignment]
+ await db.commit()
+
+ deleted_count: int = result.rowcount or 0
+ logger.info(
+ "Cleaned up %d expired or used email verification tokens", deleted_count
+ )
+ return deleted_count
diff --git a/src/ai_trading_discipline_copilot/services/password_reset_service.py b/src/ai_trading_discipline_copilot/services/password_reset_service.py
new file mode 100644
index 0000000..23a2cdc
--- /dev/null
+++ b/src/ai_trading_discipline_copilot/services/password_reset_service.py
@@ -0,0 +1,212 @@
+"""Password reset service business logic."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import secrets
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+from sqlalchemy import delete, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException
+from ai_trading_discipline_copilot.core.security import hash_password
+from ai_trading_discipline_copilot.models.password_reset_token import PasswordResetToken
+from ai_trading_discipline_copilot.services.refresh_token_service import (
+ RefreshTokenService,
+)
+
+if TYPE_CHECKING:
+ from ai_trading_discipline_copilot.models.user import User
+
+logger = logging.getLogger(__name__)
+
+
+class PasswordResetService:
+ """Business logic for handling password resets."""
+
+ @staticmethod
+ def generate_token() -> str:
+ """Generate a secure random token.
+
+ Returns:
+ str: A secure random URL-safe string.
+ """
+ return secrets.token_urlsafe(32)
+
+ @staticmethod
+ def hash_token(token: str) -> str:
+ """Return the SHA-256 hash of a token.
+
+ Args:
+ token: The plain-text token.
+
+ Returns:
+ str: The hex-encoded SHA-256 hash of the token.
+ """
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+ @staticmethod
+ async def create_reset_token(
+ db: AsyncSession,
+ user: User,
+ ) -> str:
+ """Create a new password reset token.
+
+ Generates a token, hashes it, deletes previous unused reset tokens for
+ the user, inserts a new PasswordResetToken that expires in 15 minutes,
+ commits the transaction, and returns the plain-text token.
+
+ Args:
+ db: The database session.
+ user: The user requesting the reset.
+
+ Returns:
+ str: The plain-text token.
+ """
+ plain_token = PasswordResetService.generate_token()
+ token_hash = PasswordResetService.hash_token(plain_token)
+
+ # Delete previous unused reset tokens for the same user
+ await db.execute(
+ delete(PasswordResetToken).where(
+ PasswordResetToken.user_id == user.id,
+ PasswordResetToken.used_at.is_(None),
+ )
+ )
+
+ # Insert new PasswordResetToken expiring in 15 minutes
+ expires_at = datetime.now(UTC) + timedelta(minutes=15)
+ reset_token = PasswordResetToken(
+ user_id=user.id,
+ token_hash=token_hash,
+ expires_at=expires_at,
+ )
+ db.add(reset_token)
+ await db.commit()
+
+ logger.info(
+ "Created password reset token for user ID: %s expiring at %s",
+ user.id,
+ expires_at,
+ )
+
+ return plain_token
+
+ @staticmethod
+ async def validate_token(
+ db: AsyncSession,
+ token: str,
+ ) -> PasswordResetToken:
+ """Validate a password reset token.
+
+ Hashes the incoming token, checks the database, validates that the token
+ exists, is not expired, and has not been used yet.
+
+ Args:
+ db: The database session.
+ token: The plain-text reset token.
+
+ Returns:
+ PasswordResetToken: The validated token model instance with user loaded.
+
+ Raises:
+ UnauthorizedException: If the token is invalid, expired, or already used.
+ """
+ token_hash = PasswordResetService.hash_token(token)
+ stmt = (
+ select(PasswordResetToken)
+ .where(PasswordResetToken.token_hash == token_hash)
+ .options(selectinload(PasswordResetToken.user))
+ )
+ result = await db.execute(stmt)
+ db_token = result.scalar_one_or_none()
+
+ if db_token is None:
+ logger.warning("Password reset token not found in database.")
+ raise UnauthorizedException("Invalid or expired password reset token")
+
+ if db_token.used_at is not None:
+ logger.warning(
+ "Password reset token already used at %s.", db_token.used_at
+ )
+ raise UnauthorizedException("Invalid or expired password reset token")
+
+ if db_token.expires_at <= datetime.now(UTC):
+ logger.warning(
+ "Password reset token expired at %s.", db_token.expires_at
+ )
+ raise UnauthorizedException("Invalid or expired password reset token")
+
+ return db_token
+
+ @staticmethod
+ async def reset_password(
+ db: AsyncSession,
+ token: str,
+ new_password: str,
+ ) -> None:
+ """Reset the user's password using the provided reset token.
+
+ Validates the token, loads the user, hashes the new password, updates
+ the user's password hash, marks the token as used, and revokes all active
+ refresh sessions for the user.
+
+ Args:
+ db: The database session.
+ token: The plain-text reset token.
+ new_password: The new plain-text password.
+
+ Raises:
+ UnauthorizedException: If the token is invalid or expired.
+ """
+ db_token = await PasswordResetService.validate_token(db, token)
+ user = db_token.user
+
+ # Hash new password
+ hashed = hash_password(new_password)
+ user.hashed_password = hashed
+
+ # Mark token as used
+ db_token.used_at = datetime.now(UTC)
+
+ # Revoke every active refresh session
+ await RefreshTokenService.revoke_all_for_user(db, user.id)
+
+ await db.commit()
+ logger.info(
+ "Successfully reset password and revoked all sessions for user ID: %s",
+ user.id,
+ )
+
+ @staticmethod
+ async def cleanup_expired_tokens(
+ db: AsyncSession,
+ ) -> int:
+ """Delete all expired or already-used password reset tokens.
+
+ Args:
+ db: The database session.
+
+ Returns:
+ int: The number of deleted tokens.
+ """
+ from sqlalchemy.engine import CursorResult
+
+ stmt = delete(PasswordResetToken).where(
+ or_(
+ PasswordResetToken.expires_at <= datetime.now(UTC),
+ PasswordResetToken.used_at.is_not(None),
+ )
+ )
+ result: CursorResult[tuple[()]] = await db.execute(stmt) # type: ignore[assignment]
+ await db.commit()
+
+ deleted_count: int = result.rowcount or 0
+ logger.info(
+ "Cleaned up %d expired or used password reset tokens", deleted_count
+ )
+ return deleted_count
diff --git a/src/ai_trading_discipline_copilot/services/user_service.py b/src/ai_trading_discipline_copilot/services/user_service.py
index ae33e9f..135d878 100644
--- a/src/ai_trading_discipline_copilot/services/user_service.py
+++ b/src/ai_trading_discipline_copilot/services/user_service.py
@@ -49,6 +49,7 @@ async def register_user(
username=user_data.username,
email=user_data.email,
hashed_password=hash_password(user_data.password),
+ is_verified=False,
)
db.add(user)
diff --git a/tests/conftest.py b/tests/conftest.py
index d4e933f..57d8930 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -110,3 +110,24 @@ async def override_get_db() -> AsyncGenerator[AsyncSession]:
yield ac
app.dependency_overrides.clear()
+
+
+@pytest.fixture(autouse=True)
+def mock_resend_emails() -> Generator[None]:
+ """Globally mock resend email calls during tests to prevent API key errors."""
+ from unittest.mock import patch
+ with patch("resend.Emails.send") as mock_send, patch("resend.Emails.send_async") as mock_send_async:
+ yield mock_send
+
+
+# Event listener to set is_verified=True on User instantiation if not explicitly passed.
+# This prevents existing tests from failing since email verification wasn't present.
+from sqlalchemy import event
+from ai_trading_discipline_copilot.models.user import User
+
+
+@event.listens_for(User, "init")
+def receive_init(target, args, kwargs) -> None:
+ if "is_verified" not in kwargs:
+ kwargs["is_verified"] = True
+
diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py
new file mode 100644
index 0000000..be0f803
--- /dev/null
+++ b/tests/test_email_verification.py
@@ -0,0 +1,385 @@
+from datetime import UTC, datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from ai_trading_discipline_copilot.models.email_verification_token import (
+ EmailVerificationToken,
+)
+from ai_trading_discipline_copilot.models.user import User
+from ai_trading_discipline_copilot.services.email_verification_service import (
+ EmailVerificationService,
+)
+
+TEST_PASSWORD = "StrongPass1!" # noqa: S105
+
+
+@pytest.fixture
+async def unverified_user(db_session: AsyncSession) -> User:
+ """Create an unverified test user in the database."""
+ from ai_trading_discipline_copilot.core.security import hash_password
+
+ user = User(
+ username="unverified",
+ email="unverified@example.com",
+ hashed_password=hash_password(TEST_PASSWORD),
+ is_verified=False,
+ )
+ db_session.add(user)
+ await db_session.commit()
+ await db_session.refresh(user)
+ return user
+
+
+@pytest.mark.anyio
+async def test_register_sends_verification_email(
+ client: AsyncClient,
+ db_session: AsyncSession,
+) -> None:
+ """Test that registration automatically generates token and queues email."""
+ payload = {
+ "username": "newreg",
+ "email": "newreg@example.com",
+ "password": TEST_PASSWORD,
+ }
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email"
+ ) as mock_send:
+ response = await client.post("/auth/register", json=payload)
+ assert response.status_code == 201
+
+ # Check user is created with is_verified=False
+ result = await db_session.execute(
+ select(User).where(User.username == "newreg")
+ )
+ user = result.scalar_one_or_none()
+ assert user is not None
+ assert user.is_verified is False
+
+ # Verify token created in DB
+ result_token = await db_session.execute(
+ select(EmailVerificationToken).where(
+ EmailVerificationToken.user_id == user.id
+ )
+ )
+ token = result_token.scalar_one_or_none()
+ assert token is not None
+ assert token.used_at is None
+
+ # Verify email task was triggered
+ mock_send.assert_called_once()
+ call_kwargs = mock_send.call_args.kwargs
+ assert call_kwargs["to"] == "newreg@example.com"
+ assert "/verify-email?token=" in call_kwargs["verification_url"]
+
+
+@pytest.mark.anyio
+async def test_verify_email_success(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test successful email verification endpoint."""
+ plain = await EmailVerificationService.create_verification_token(
+ db_session, unverified_user
+ )
+
+ response = await client.post(
+ "/auth/verify-email",
+ json={"token": plain},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == "Email verified successfully."
+
+ # Check database changes
+ await db_session.refresh(unverified_user)
+ assert unverified_user.is_verified is True
+
+ token_hash = EmailVerificationService.hash_token(plain)
+ result = await db_session.execute(
+ select(EmailVerificationToken).where(
+ EmailVerificationToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ assert db_token.used_at is not None
+
+
+@pytest.mark.anyio
+async def test_verify_email_invalid_token(
+ client: AsyncClient,
+) -> None:
+ """Test verify-email endpoint fails with invalid token."""
+ response = await client.post(
+ "/auth/verify-email",
+ json={"token": "invalidtoken" * 3},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired email verification token"
+
+
+@pytest.mark.anyio
+async def test_verify_email_expired_token(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test verify-email endpoint fails with expired token."""
+ plain = await EmailVerificationService.create_verification_token(
+ db_session, unverified_user
+ )
+ token_hash = EmailVerificationService.hash_token(plain)
+
+ # Expire token in DB
+ result = await db_session.execute(
+ select(EmailVerificationToken).where(
+ EmailVerificationToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1)
+ await db_session.commit()
+
+ response = await client.post(
+ "/auth/verify-email",
+ json={"token": plain},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired email verification token"
+
+
+@pytest.mark.anyio
+async def test_verify_email_already_used_token(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test verify-email endpoint fails with already used token."""
+ plain = await EmailVerificationService.create_verification_token(
+ db_session, unverified_user
+ )
+ token_hash = EmailVerificationService.hash_token(plain)
+
+ # Mark token used in DB
+ result = await db_session.execute(
+ select(EmailVerificationToken).where(
+ EmailVerificationToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.used_at = datetime.now(UTC)
+ await db_session.commit()
+
+ response = await client.post(
+ "/auth/verify-email",
+ json={"token": plain},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired email verification token"
+
+
+@pytest.mark.anyio
+async def test_login_before_verification_fails(
+ client: AsyncClient,
+ unverified_user: User,
+) -> None:
+ """Test that login attempts for unverified users return 403 Forbidden."""
+ response = await client.post(
+ "/auth/login",
+ data={"username": unverified_user.username, "password": TEST_PASSWORD},
+ )
+ assert response.status_code == 403
+ assert response.json()["detail"] == "Please verify your email before logging in."
+
+
+@pytest.mark.anyio
+async def test_login_after_verification_succeeds(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test that login succeeds once user is verified."""
+ # Verify user
+ unverified_user.is_verified = True
+ await db_session.commit()
+
+ response = await client.post(
+ "/auth/login",
+ data={"username": unverified_user.username, "password": TEST_PASSWORD},
+ )
+ assert response.status_code == 200
+ assert "access_token" in response.json()
+
+
+@pytest.mark.anyio
+async def test_resend_verification_success(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test resending verification link to unverified user."""
+ # Pre-create token to test deletion of previous token
+ await EmailVerificationService.create_verification_token(
+ db_session, unverified_user
+ )
+
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email"
+ ) as mock_send:
+ response = await client.post(
+ "/auth/resend-verification",
+ json={"email": unverified_user.email},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == "Verification email sent."
+
+ # Verify email was sent
+ mock_send.assert_called_once()
+ call_kwargs = mock_send.call_args.kwargs
+ assert call_kwargs["to"] == unverified_user.email
+
+ # Verify old token deleted and only 1 remains
+ result = await db_session.execute(
+ select(EmailVerificationToken).where(
+ EmailVerificationToken.user_id == unverified_user.id
+ )
+ )
+ remaining = result.scalars().all()
+ assert len(remaining) == 1
+
+
+@pytest.mark.anyio
+async def test_resend_verification_already_verified(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test resending to already verified user returns success but sends no email."""
+ unverified_user.is_verified = True
+ await db_session.commit()
+
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email"
+ ) as mock_send:
+ response = await client.post(
+ "/auth/resend-verification",
+ json={"email": unverified_user.email},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == "Verification email sent."
+
+ # Verify no email was sent
+ mock_send.assert_not_called()
+
+
+@pytest.mark.anyio
+async def test_resend_verification_non_existing_email(
+ client: AsyncClient,
+) -> None:
+ """Test resending to non-existent email returns success but sends no email."""
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email"
+ ) as mock_send:
+ response = await client.post(
+ "/auth/resend-verification",
+ json={"email": "notfound@example.com"},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == "Verification email sent."
+
+ # Verify no email was sent
+ mock_send.assert_not_called()
+
+
+@pytest.mark.anyio
+async def test_cleanup_expired_tokens(
+ db_session: AsyncSession,
+ unverified_user: User,
+) -> None:
+ """Test cleanup function deletes used/expired verification tokens."""
+ now = datetime.now(UTC)
+
+ # 1. Expired unused token
+ t1 = EmailVerificationToken(
+ user_id=unverified_user.id,
+ token_hash="hash1", # noqa: S106
+ expires_at=now - timedelta(minutes=1),
+ used_at=None,
+ )
+ # 2. Expired used token
+ t2 = EmailVerificationToken(
+ user_id=unverified_user.id,
+ token_hash="hash2", # noqa: S106
+ expires_at=now - timedelta(minutes=1),
+ used_at=now - timedelta(minutes=2),
+ )
+ # 3. Non-expired used token
+ t3 = EmailVerificationToken(
+ user_id=unverified_user.id,
+ token_hash="hash3", # noqa: S106
+ expires_at=now + timedelta(hours=24),
+ used_at=now - timedelta(seconds=10),
+ )
+ # 4. Non-expired unused token (valid)
+ t4 = EmailVerificationToken(
+ user_id=unverified_user.id,
+ token_hash="hash4", # noqa: S106
+ expires_at=now + timedelta(hours=24),
+ used_at=None,
+ )
+
+ db_session.add_all([t1, t2, t3, t4])
+ await db_session.commit()
+
+ deleted = await EmailVerificationService.cleanup_expired_tokens(db_session)
+ assert deleted == 3
+
+ # Only t4 remains
+ result = await db_session.execute(select(EmailVerificationToken))
+ remaining = result.scalars().all()
+ assert len(remaining) == 1
+ assert remaining[0].token_hash == "hash4" # noqa: S105
+
+
+@pytest.mark.anyio
+async def test_register_verification_email_failure_logged(
+ client: AsyncClient,
+ db_session: AsyncSession,
+) -> None:
+ """Test registration succeeds even if background email fails.
+
+ Verifies that the error is logged.
+ """
+ payload = {
+ "username": "emailfail",
+ "email": "emailfail@example.com",
+ "password": TEST_PASSWORD,
+ }
+
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_verification_email",
+ side_effect=Exception("SMTP or API connection timeout"),
+ ), patch(
+ "ai_trading_discipline_copilot.routers.auth.logger.exception"
+ ) as mock_log:
+ response = await client.post("/auth/register", json=payload)
+
+ # Registration must still succeed immediately and return 201
+ assert response.status_code == 201
+
+ # User should still be created
+ result = await db_session.execute(
+ select(User).where(User.username == "emailfail")
+ )
+ user = result.scalar_one_or_none()
+ assert user is not None
+ assert user.is_verified is False
+
+ # Verify logger.exception was called once with context
+ mock_log.assert_called_once()
+ log_args = mock_log.call_args[0]
+ assert "Failed to send email [type=verification]" in log_args[0]
+
diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py
new file mode 100644
index 0000000..1da06cf
--- /dev/null
+++ b/tests/test_password_reset.py
@@ -0,0 +1,546 @@
+from datetime import UTC, datetime, timedelta
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from ai_trading_discipline_copilot.core.exceptions import UnauthorizedException
+from ai_trading_discipline_copilot.core.security import hash_password, verify_password
+from ai_trading_discipline_copilot.models.password_reset_token import PasswordResetToken
+from ai_trading_discipline_copilot.models.refresh_token import RefreshToken
+from ai_trading_discipline_copilot.models.user import User
+from ai_trading_discipline_copilot.services.password_reset_service import (
+ PasswordResetService,
+)
+from ai_trading_discipline_copilot.services.refresh_token_service import (
+ RefreshTokenService,
+)
+
+TEST_PASSWORD = "OldPassword1!" # noqa: S105
+NEW_TEST_PASSWORD = "NewPassword2?" # noqa: S105
+
+
+@pytest.fixture
+async def test_user(db_session: AsyncSession) -> User:
+ """Create a test user in the database."""
+ user = User(
+ username="resetuser",
+ email="resetuser@example.com",
+ hashed_password=hash_password(TEST_PASSWORD),
+ is_verified=True,
+ )
+ db_session.add(user)
+ await db_session.commit()
+ await db_session.refresh(user)
+ return user
+
+
+@pytest.mark.anyio
+async def test_generate_and_hash_token() -> None:
+ """Test generating a plain-text token and hashing it."""
+ plain = PasswordResetService.generate_token()
+ assert isinstance(plain, str)
+ assert len(plain) >= 32
+
+ hashed = PasswordResetService.hash_token(plain)
+ assert isinstance(hashed, str)
+ assert len(hashed) == 64 # SHA-256 hex digest is 64 chars
+
+
+@pytest.mark.anyio
+async def test_create_reset_token(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test creating a password reset token in the database."""
+ plain_token = await PasswordResetService.create_reset_token(
+ db_session, test_user
+ )
+ assert plain_token is not None
+
+ token_hash = PasswordResetService.hash_token(plain_token)
+
+ # Verify token is saved in DB
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one_or_none()
+ assert db_token is not None
+ assert db_token.user_id == test_user.id
+ assert db_token.used_at is None
+ # Check expiration is roughly 15 minutes in future
+ time_diff = db_token.expires_at - datetime.now(UTC)
+ assert timedelta(minutes=14) < time_diff < timedelta(minutes=16)
+
+
+@pytest.mark.anyio
+async def test_create_reset_token_deletes_previous_unused(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test that creating a new token deletes any previous unused tokens for user."""
+ # First token
+ token_1 = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_1 = PasswordResetService.hash_token(token_1)
+
+ # Second token
+ token_2 = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_2 = PasswordResetService.hash_token(token_2)
+
+ # First token should be deleted
+ result_1 = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_1
+ )
+ )
+ assert result_1.scalar_one_or_none() is None
+
+ # Second token should exist
+ result_2 = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_2
+ )
+ )
+ assert result_2.scalar_one_or_none() is not None
+
+
+@pytest.mark.anyio
+async def test_create_reset_token_keeps_previous_used(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test that creating a new token does not delete previously used tokens."""
+ token_1 = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_1 = PasswordResetService.hash_token(token_1)
+
+ # Mark first token as used
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_1
+ )
+ )
+ db_token_1 = result.scalar_one()
+ db_token_1.used_at = datetime.now(UTC)
+ await db_session.commit()
+
+ # Create new token
+ token_2 = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_2 = PasswordResetService.hash_token(token_2)
+
+ # Both tokens should exist
+ result_1 = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_1
+ )
+ )
+ assert result_1.scalar_one_or_none() is not None
+
+ result_2 = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_2
+ )
+ )
+ assert result_2.scalar_one_or_none() is not None
+
+
+@pytest.mark.anyio
+async def test_validate_token_success(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test validating a valid token."""
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ db_token = await PasswordResetService.validate_token(db_session, plain)
+ assert db_token is not None
+ assert db_token.user.id == test_user.id
+
+
+@pytest.mark.anyio
+async def test_validate_token_not_found(
+ db_session: AsyncSession,
+) -> None:
+ """Test validation fails for non-existent token."""
+ with pytest.raises(UnauthorizedException) as exc_info:
+ await PasswordResetService.validate_token(db_session, "nonexistenttoken" * 3)
+ assert exc_info.value.detail == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_validate_token_expired(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test validation fails for an expired token."""
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_val = PasswordResetService.hash_token(plain)
+
+ # Make token expired in the database
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_val
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1)
+ await db_session.commit()
+
+ with pytest.raises(UnauthorizedException) as exc_info:
+ await PasswordResetService.validate_token(db_session, plain)
+ assert exc_info.value.detail == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_validate_token_already_used(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test validation fails for a token already used."""
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ hash_val = PasswordResetService.hash_token(plain)
+
+ # Make token used in the database
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == hash_val
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.used_at = datetime.now(UTC)
+ await db_session.commit()
+
+ with pytest.raises(UnauthorizedException) as exc_info:
+ await PasswordResetService.validate_token(db_session, plain)
+ assert exc_info.value.detail == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_reset_password_success(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test successful password reset updates user password.
+
+ Also verifies that it revokes all active refresh sessions.
+ """
+ # Pre-add some refresh sessions for user
+ session_1 = RefreshToken(
+ user_id=test_user.id,
+ token_hash="hash1", # noqa: S106
+ jti="jti1",
+ expires_at=datetime.now(UTC) + timedelta(days=7),
+ )
+ session_2 = RefreshToken(
+ user_id=test_user.id,
+ token_hash="hash2", # noqa: S106
+ jti="jti2",
+ expires_at=datetime.now(UTC) + timedelta(days=7),
+ )
+ db_session.add_all([session_1, session_2])
+ await db_session.commit()
+
+ # Create reset token
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ token_hash = PasswordResetService.hash_token(plain)
+
+ # Reset password
+ await PasswordResetService.reset_password(db_session, plain, NEW_TEST_PASSWORD)
+
+ # Verify password was updated
+ await db_session.refresh(test_user)
+ assert verify_password(NEW_TEST_PASSWORD, test_user.hashed_password)
+
+ # Verify token was marked used
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ assert db_token.used_at is not None
+
+ # Verify refresh sessions were revoked
+ sessions = await RefreshTokenService.get_active_sessions_for_user(
+ db_session, test_user.id
+ )
+ assert len(sessions) == 0
+
+ # Ensure sessions have revoked_at timestamp set
+ res = await db_session.execute(
+ select(RefreshToken).where(RefreshToken.user_id == test_user.id)
+ )
+ db_sessions = res.scalars().all()
+ assert len(db_sessions) == 2
+ for s in db_sessions:
+ assert s.revoked_at is not None
+
+
+@pytest.mark.anyio
+async def test_cleanup_expired_tokens(
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test that cleanup deletes expired and already used tokens.
+
+ Also ensures that it preserves valid unused ones.
+ """
+ now = datetime.now(UTC)
+
+ # 1. Expired unused token
+ t1 = PasswordResetToken(
+ user_id=test_user.id,
+ token_hash="hash1", # noqa: S106
+ expires_at=now - timedelta(minutes=1),
+ used_at=None,
+ )
+ # 2. Expired used token
+ t2 = PasswordResetToken(
+ user_id=test_user.id,
+ token_hash="hash2", # noqa: S106
+ expires_at=now - timedelta(minutes=1),
+ used_at=now - timedelta(minutes=2),
+ )
+ # 3. Non-expired used token
+ t3 = PasswordResetToken(
+ user_id=test_user.id,
+ token_hash="hash3", # noqa: S106
+ expires_at=now + timedelta(minutes=15),
+ used_at=now - timedelta(seconds=10),
+ )
+ # 4. Non-expired unused token (valid)
+ t4 = PasswordResetToken(
+ user_id=test_user.id,
+ token_hash="hash4", # noqa: S106
+ expires_at=now + timedelta(minutes=15),
+ used_at=None,
+ )
+
+ db_session.add_all([t1, t2, t3, t4])
+ await db_session.commit()
+
+ deleted = await PasswordResetService.cleanup_expired_tokens(db_session)
+ assert deleted == 3
+
+ # Only t4 should remain in database
+ result = await db_session.execute(select(PasswordResetToken))
+ remaining = result.scalars().all()
+ assert len(remaining) == 1
+ assert remaining[0].token_hash == "hash4" # noqa: S105
+
+
+# =====================================================================
+# API Endpoints Integration Tests
+# =====================================================================
+
+
+@pytest.mark.anyio
+async def test_forgot_password_existing_email(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test forgot-password endpoint with an existing email address."""
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_email"
+ ) as mock_send:
+ response = await client.post(
+ "/auth/forgot-password",
+ json={"email": test_user.email},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == (
+ "If an account with that email exists, a password reset link has been sent."
+ )
+
+ # Check database: reset token should have been created
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.user_id == test_user.id
+ )
+ )
+ token = result.scalar_one_or_none()
+ assert token is not None
+ assert token.used_at is None
+
+ # Verify email send was called
+ mock_send.assert_called_once()
+ call_kwargs = mock_send.call_args.kwargs
+ assert call_kwargs["to"] == test_user.email
+ assert "Reset your password" in call_kwargs["subject"]
+ assert "Reset Password" in call_kwargs["html"]
+ assert "/reset-password?token=" in call_kwargs["html"]
+
+
+@pytest.mark.anyio
+async def test_forgot_password_non_existing_email(
+ client: AsyncClient,
+ db_session: AsyncSession,
+) -> None:
+ """Test forgot-password endpoint with a non-existing email address."""
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_email"
+ ) as mock_send:
+ response = await client.post(
+ "/auth/forgot-password",
+ json={"email": "nonexisting_user_email@example.com"},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == (
+ "If an account with that email exists, a password reset link has been sent."
+ )
+
+ # Verify no email send was called
+ mock_send.assert_not_called()
+
+
+@pytest.mark.anyio
+async def test_reset_password_endpoint_success(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test successful password reset endpoint."""
+ # Pre-add refresh session
+ session = RefreshToken(
+ user_id=test_user.id,
+ token_hash="hash_val", # noqa: S106
+ jti="jti_val",
+ expires_at=datetime.now(UTC) + timedelta(days=7),
+ )
+ db_session.add(session)
+ await db_session.commit()
+
+ # Generate token
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+
+ response = await client.post(
+ "/auth/reset-password",
+ json={"token": plain, "new_password": NEW_TEST_PASSWORD},
+ )
+ assert response.status_code == 200
+ assert response.json()["message"] == "Password reset successful."
+
+ # Verify refresh sessions were revoked
+ sessions = await RefreshTokenService.get_active_sessions_for_user(
+ db_session, test_user.id
+ )
+ assert len(sessions) == 0
+
+ # Login fails with old password
+ login_response_old = await client.post(
+ "/auth/login",
+ data={"username": test_user.username, "password": TEST_PASSWORD},
+ )
+ assert login_response_old.status_code == 401
+ assert login_response_old.json()["detail"] == "Invalid username or password"
+
+ # Login succeeds with new password
+ login_response_new = await client.post(
+ "/auth/login",
+ data={"username": test_user.username, "password": NEW_TEST_PASSWORD},
+ )
+ assert login_response_new.status_code == 200
+ assert "access_token" in login_response_new.json()
+
+
+
+@pytest.mark.anyio
+async def test_reset_password_endpoint_invalid_token(
+ client: AsyncClient,
+) -> None:
+ """Test reset-password endpoint with an invalid token."""
+ response = await client.post(
+ "/auth/reset-password",
+ json={"token": "invalidtoken" * 3, "new_password": NEW_TEST_PASSWORD},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_reset_password_endpoint_expired_token(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test reset-password endpoint with an expired token."""
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ token_hash = PasswordResetService.hash_token(plain)
+
+ # Expire token in DB
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.expires_at = datetime.now(UTC) - timedelta(seconds=1)
+ await db_session.commit()
+
+ response = await client.post(
+ "/auth/reset-password",
+ json={"token": plain, "new_password": NEW_TEST_PASSWORD},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_reset_password_endpoint_already_used_token(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test reset-password endpoint with an already used token."""
+ plain = await PasswordResetService.create_reset_token(db_session, test_user)
+ token_hash = PasswordResetService.hash_token(plain)
+
+ # Use token in DB
+ result = await db_session.execute(
+ select(PasswordResetToken).where(
+ PasswordResetToken.token_hash == token_hash
+ )
+ )
+ db_token = result.scalar_one()
+ db_token.used_at = datetime.now(UTC)
+ await db_session.commit()
+
+ response = await client.post(
+ "/auth/reset-password",
+ json={"token": plain, "new_password": NEW_TEST_PASSWORD},
+ )
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Invalid or expired password reset token"
+
+
+@pytest.mark.anyio
+async def test_forgot_password_email_failure_logged(
+ client: AsyncClient,
+ db_session: AsyncSession,
+ test_user: User,
+) -> None:
+ """Test requesting reset succeeds even if background email fails.
+
+ Verifies that the error is logged.
+ """
+ with patch(
+ "ai_trading_discipline_copilot.services.email_service.EmailService.send_email",
+ side_effect=Exception("Resend API key invalid"),
+ ), patch(
+ "ai_trading_discipline_copilot.routers.auth.logger.exception"
+ ) as mock_log:
+ response = await client.post(
+ "/auth/forgot-password",
+ json={"email": test_user.email},
+ )
+ assert response.status_code == 200
+ assert "password reset link has been sent" in response.json()["message"]
+
+ # Logged failure with password_reset context
+ mock_log.assert_called_once()
+ log_args = mock_log.call_args[0]
+ assert "Failed to send email [type=password_reset]" in log_args[0]
+
diff --git a/uv.lock b/uv.lock
index 47369d7..45a1a70 100644
--- a/uv.lock
+++ b/uv.lock
@@ -20,6 +20,7 @@ dependencies = [
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
+ { name = "resend", extra = ["async"] },
{ name = "sqlalchemy" },
]
@@ -43,6 +44,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0" },
+ { name = "resend", extras = ["async"], specifier = ">=2.32.2" },
{ name = "sqlalchemy", specifier = ">=2.0.51" },
]
@@ -302,6 +304,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
[[package]]
name = "click"
version = "8.4.2"
@@ -1238,6 +1297,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "resend"
+version = "2.32.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/d5/2c46334104cce931170d756c7478263744b3eb168179c329d90a065f9b6b/resend-2.32.2.tar.gz", hash = "sha256:448001810f32e7aea39bab27318263fdc69f5764be16b7c6241ed7714dc07ea3", size = 46202, upload-time = "2026-06-17T19:47:28.243Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/e9/64b7f15e4d2b9bc363e1a6e0abc518f7aea4b8dfeab8d15a25e508c31fb2/resend-2.32.2-py2.py3-none-any.whl", hash = "sha256:1a8df5ef54b3a5cb7bb18b745f85fda07f1fa8f79b83dd9a25f6e37fbd625ef2", size = 72664, upload-time = "2026-06-17T19:47:26.915Z" },
+]
+
+[package.optional-dependencies]
+async = [
+ { name = "httpx" },
+]
+
[[package]]
name = "rich"
version = "15.0.0"