Skip to content

ENG-25: Implement email verification, password recovery, and Resend email integration#9

Merged
Anjaldev-vk merged 5 commits into
developfrom
feature/ENG-25-forgot-password
Jul 7, 2026
Merged

ENG-25: Implement email verification, password recovery, and Resend email integration#9
Anjaldev-vk merged 5 commits into
developfrom
feature/ENG-25-forgot-password

Conversation

@sreenandpk

@sreenandpk sreenandpk commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR introduces a complete production-ready email authentication workflow for the AI Trading Discipline Copilot.

The authentication system now supports secure email verification, password recovery, refresh token session management, and modern email delivery using Resend.


✨ Features Added

Email Verification

  • Email verification during user registration
  • Verification token generation using secure random tokens
  • SHA-256 hashed verification tokens
  • 24-hour verification token expiration
  • Single-use verification tokens
  • Resend verification email endpoint
  • Prevent login until email is verified
  • Generic responses to prevent account enumeration

Password Recovery

  • Forgot Password endpoint
  • Reset Password endpoint
  • Secure password reset emails
  • SHA-256 hashed reset tokens
  • Single-use reset tokens
  • 15-minute expiration
  • Automatic revocation of all active sessions after password reset

Email Integration

  • Integrated Resend
  • Professional HTML email templates
  • Background email sending using FastAPI BackgroundTasks
  • Configurable sender and frontend URL via environment variables

Authentication Improvements

  • Login now requires verified email
  • Secure refresh token session revocation after password changes
  • Automatic cleanup for expired verification and password reset tokens

🛡️ Security Improvements

  • Refresh Token Rotation
  • Refresh Token Reuse Detection
  • SHA-256 token hashing
  • Bcrypt password hashing
  • Generic error messages
  • Account enumeration protection
  • Single-use email verification tokens
  • Single-use password reset tokens
  • Session invalidation after password reset

🧪 Testing

Manual Testing

  • ✅ Register user
  • ✅ Receive verification email
  • ✅ Verify email
  • ✅ Login after verification
  • ✅ Resend verification email
  • ✅ Forgot password
  • ✅ Reset password
  • ✅ Old password rejected
  • ✅ New password accepted

Automated Testing

  • 60 tests passing
  • 84% code coverage
  • Ruff checks passing
  • MyPy checks passing

Files Added

  • EmailVerificationToken model
  • EmailVerificationService
  • PasswordResetService
  • Email verification schemas
  • Password reset schemas
  • Email templates
  • Alembic migrations
  • Email verification tests
  • Password reset tests

Notes

This PR completes the production-ready authentication email workflow using Resend while keeping the architecture modular and aligned with the existing authentication system.

Summary by CodeRabbit

  • New Features

    • Added email verification and password reset flows, including resend/forgot/reset/verify actions.
    • Introduced background email sending for verification and recovery messages.
    • Added admin session cleanup and support for email-based account recovery.
  • Bug Fixes

    • Login now blocks unverified accounts until email verification is completed.
    • Password resets now revoke active sessions after a successful change.
  • Chores

    • Added required email-related configuration and database support for token tracking.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a97f697-91b9-4d8b-a200-99a500cffa24

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ENG-25-forgot-password

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (14)
src/ai_trading_discipline_copilot/services/email_service.py (2)

42-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local imports inside method.

import html and from datetime import UTC, datetime are imported inside send_verification_email rather than at module top. Moving these to the top-level imports is more idiomatic and avoids repeated import overhead on every call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/services/email_service.py` around lines 42
- 43, The imports in send_verification_email are being done locally instead of
at module scope. Move the html import and the UTC/datetime import to the
top-level import section in email_service.py so
EmailService.send_verification_email uses the shared module imports rather than
re-importing on every call.

26-33: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No error handling around outbound email send.

Both call sites in send_email/send_verification_email don't catch exceptions from Resend. Since these run inside BackgroundTasks, an unhandled exception is only logged internally by FastAPI/Starlette with no retry or alerting — verification/reset/registration emails can silently fail to send while the caller already returned a "success" response to the user.

Consider wrapping the send call with try/except plus structured logging (and optionally a retry) so failures are observable.

Also applies to: 141-145

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/services/email_service.py` around lines 26
- 33, The Resend outbound send in send_email/send_verification_email has no
exception handling, so BackgroundTasks failures can be swallowed and never
surfaced. Wrap the resend.Emails.send call in try/except inside the email
service methods, add structured logging with the exception details and
recipient/subject context, and make sure the failure path is observable (and
optionally retried) instead of silently succeeding. Use the send_email and
send_verification_email symbols to update both call sites consistently.
src/ai_trading_discipline_copilot/schemas/email_verification.py (1)

6-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Inconsistent token validation vs. ResetPasswordRequest.

ResetPasswordRequest.token enforces min_length=32 (matching secrets.token_urlsafe(32)), but VerifyEmailRequest.token has no length constraint despite using the same token generation scheme. Consider aligning for consistency and to reject malformed input earlier.

♻️ Suggested fix
+from pydantic import BaseModel, EmailStr, Field
+
 class VerifyEmailRequest(BaseModel):
     """Request to verify email."""

-    token: str
+    token: str = Field(min_length=32)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/schemas/email_verification.py` around lines
6 - 9, Align VerifyEmailRequest.token with ResetPasswordRequest.token by adding
the same minimum length validation used for the shared token format generated by
secrets.token_urlsafe(32). Update the VerifyEmailRequest model in
email_verification.py so it rejects too-short malformed tokens early, keeping
its validation consistent with ResetPasswordRequest and the rest of the
token-based flow.
src/ai_trading_discipline_copilot/services/__init__.py (1)

7-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

__all__ is not sorted (RUF022).

Ruff flags the ordering. If this rule is enforced in CI it will fail; apply an isort-style sort.

Proposed ordering
 __all__ = [
-    "UserService",
-    "PasswordResetService",
     "EmailVerificationService",
+    "PasswordResetService",
+    "UserService",
 ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/services/__init__.py` around lines 7 - 11,
The __all__ export list in the services package is not in the sorted order
expected by Ruff (RUF022). Update the __all__ definition in the __init__ module
so the exported names are ordered alphabetically using isort-style sorting,
keeping the existing symbols UserService, PasswordResetService, and
EmailVerificationService in the corrected order.

Source: Linters/SAST tools

src/ai_trading_discipline_copilot/routers/auth.py (1)

344-457: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

send_reset_email_task duplicates the email HTML/template logic in EmailService.

The inline reset-email HTML template mirrors EmailService.send_verification_email (same styles, structure, html.escape handling, footer/year). Building templates in the router while the verification email lives in EmailService splits responsibilities and duplicates markup that will drift. Consider moving this into an EmailService.send_password_reset_email(...) method alongside send_verification_email and calling it from the background task.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/routers/auth.py` around lines 344 - 457,
The password reset email template is duplicated in send_reset_email_task instead
of living in EmailService like the verification email. Move the
HTML/template-building logic into a dedicated
EmailService.send_password_reset_email method, using the same pattern as
send_verification_email, and have send_reset_email_task only prepare inputs and
call that service method. Keep the escaping, subject, and footer/year handling
inside EmailService so email rendering stays centralized and consistent.
src/ai_trading_discipline_copilot/services/email_verification_service.py (1)

26-203: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Near-total duplication with PasswordResetService.

EmailVerificationService and PasswordResetService are structurally identical for generate_token, hash_token, create_*_token, validate_token, and cleanup_expired_tokens (only the model, expiry window, and log strings differ). This duplicated token lifecycle will drift over time (e.g., a security fix applied to one but not the other).

Consider extracting a shared generic base (e.g., a TokenService parameterized by model + expiry) so both services inherit token generation, hashing, rotation, validation, and cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/services/email_verification_service.py`
around lines 26 - 203, `EmailVerificationService` is duplicating the same token
lifecycle logic as `PasswordResetService`, so extract the shared behavior into a
reusable `TokenService`-style base/helper that owns `generate_token`,
`hash_token`, token creation/rotation, `validate_token`, and
`cleanup_expired_tokens`. Parameterize the shared logic by the token model,
expiry duration, and log message context, then make `EmailVerificationService`
and `PasswordResetService` delegate to it so only the
email-specific/user-specific pieces remain.
tests/test_password_reset.py (1)

25-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate user-creation fixture across test files.

test_user here largely duplicates unverified_user in tests/test_email_verification.py (same construction pattern, differing only in is_verified/username/email). Consider extracting a shared parametrized user factory fixture in conftest.py to reduce duplication across the two new test suites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_password_reset.py` around lines 25 - 37, The test_user fixture
duplicates the user creation logic already present in unverified_user, so
extract the shared setup into a reusable parametrized user factory in
conftest.py. Update both test_password_reset and test_email_verification to use
that shared fixture/factory, keeping only the differing fields like username,
email, and is_verified in the per-test parameters.
tests/conftest.py (1)

123-133: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Global is_verified=True default could mask a missing production default.

This event listener unconditionally forces is_verified=True for every User() construction across the entire test suite unless a test explicitly passes the field. If the actual registration/service code path ever fails to explicitly set is_verified=False (e.g. a future refactor accidentally drops the explicit kwarg, relying on a column default that doesn't exist), no test would catch it, since this hook always injects True regardless of what production code does. Consider instead giving the is_verified column an explicit SQLAlchemy-level default (e.g. default=False) so behavior is consistent in both prod and tests, or updating pre-existing fixtures explicitly rather than a blanket event hook.

Also, the sqlalchemy and User imports (Lines 125-126) are placed mid-file rather than with the top-level imports — consider consolidating for readability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` around lines 123 - 133, The test-only User init hook is
masking real default behavior by forcing is_verified=True for every User()
unless explicitly passed. Remove the blanket event listener in conftest and make
is_verified’s intended default explicit in the User model/column definition (or
update fixtures to pass it where needed) so production and tests behave
consistently. Also move the sqlalchemy event and User imports into the top-level
import section of conftest for readability.
src/ai_trading_discipline_copilot/models/password_reset_token.py (1)

49-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate 15-minute expiry constant.

The model's default=lambda: ... + timedelta(minutes=15) duplicates the same value used explicitly in PasswordResetService.create_reset_token. Since the service always overrides this default, consider extracting a shared constant (e.g. PASSWORD_RESET_TOKEN_TTL) to avoid the two literals drifting apart.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/models/password_reset_token.py` around
lines 49 - 53, The password reset token TTL is hardcoded twice, once in
PasswordResetToken.expires_at and again in
PasswordResetService.create_reset_token, which can drift over time. Extract the
15-minute value into a shared constant such as PASSWORD_RESET_TOKEN_TTL and use
it in both the model default and the service’s token creation logic so they stay
consistent.
src/ai_trading_discipline_copilot/core/config.py (2)

87-90: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Narrow the caught exception type.

Ruff flags this blind except Exception. Since only TypeAdapter(...).validate_python(...) can realistically fail here, catching pydantic.ValidationError (or pydantic_core.ValidationError) would be more precise without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/core/config.py` around lines 87 - 90, The
database URL validation in config.py is using a broad except Exception around
TypeAdapter(PostgresDsn).validate_python, which should be narrowed. Update the
try/except in the database URL parsing logic to catch the specific Pydantic
validation error type raised by validate_python, such as
pydantic.ValidationError or pydantic_core.ValidationError, while keeping the
same ValueError re-raise behavior and message formatting.

Source: Linters/SAST tools


59-68: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add basic validation for the new email/frontend settings.

resend_api_key, email_from, and frontend_url have no validation, unlike secret_key. An empty or malformed value (e.g., missing env var substituting a blank string, or a frontend_url with a trailing slash) will pass startup validation silently and only surface later as a broken verification/reset link or a failed Resend API call.

♻️ Suggested validators
+    `@field_validator`("resend_api_key")
+    `@classmethod`
+    def resend_api_key_not_empty(cls, v: SecretStr) -> SecretStr:
+        if not v.get_secret_value().strip():
+            raise ValueError("RESEND_API_KEY must not be empty")
+        return v
+
+    `@field_validator`("frontend_url")
+    `@classmethod`
+    def normalize_frontend_url(cls, v: str) -> str:
+        if not v.strip():
+            raise ValueError("FRONTEND_URL must not be empty")
+        return v.rstrip("/")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/core/config.py` around lines 59 - 68, The
new email and frontend config fields in the settings model are currently
accepted without validation, so add basic checks alongside the existing config
validation in the Config class. Ensure `resend_api_key`, `email_from`, and
`frontend_url` cannot be empty or blank, and normalize/validate `frontend_url`
so it does not keep a trailing slash that would break generated links. Reuse the
same settings validation pattern used for `secret_key`, and keep the checks
attached to the relevant fields in `core/config.py` so startup fails fast on bad
env values.
src/ai_trading_discipline_copilot/models/__init__.py (1)

8-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sort __all__ to satisfy Ruff RUF022.

Static analysis flags the list as unsorted.

♻️ Suggested sort
 __all__ = [
-    "User",
-    "RefreshToken",
-    "PasswordResetToken",
-    "EmailVerificationToken",
+    "EmailVerificationToken",
+    "PasswordResetToken",
+    "RefreshToken",
+    "User",
 ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/models/__init__.py` around lines 8 - 13,
The __all__ list in the models package is not sorted, triggering Ruff RUF022.
Reorder the exported names in __all__ in alphabetical order in the __init__
module so the static analysis rule passes, keeping the existing symbols User,
RefreshToken, PasswordResetToken, and EmailVerificationToken.

Source: Linters/SAST tools

alembic/versions/526b088ddfed_add_email_verification_tokens_table.py (1)

24-36: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Same missing expires_at index as the password reset tokens migration.

Same rationale as alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py: the email_verification_tokens table will also be swept for expired rows during cleanup, and lacks an index on expires_at.

♻️ Suggested index addition
     op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False)
+    op.create_index(op.f('ix_email_verification_tokens_expires_at'), 'email_verification_tokens', ['expires_at'], unique=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alembic/versions/526b088ddfed_add_email_verification_tokens_table.py` around
lines 24 - 36, The email verification token migration is missing the same
`expires_at` index used by the password reset tokens table, so add an index for
`expires_at` in the `op.create_table` flow for `email_verification_tokens`
alongside the existing `user_id` index. Update the migration so cleanup queries
can efficiently target expired rows, using the existing `op.create_index`
pattern and the `email_verification_tokens` table definition.
alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py (1)

24-36: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Schema matches the PasswordResetToken model contract.

Columns, FK cascade, unique constraint on token_hash, and the user_id index all align with src/ai_trading_discipline_copilot/models/password_reset_token.py.

Consider adding an index on expires_at to support the cleanup job that periodically deletes/queries expired tokens (per PR objectives: "automatic cleanup of expired verification and reset tokens"). Without it, cleanup queries filtering by expires_at < now() will require a full table scan as the table grows.

♻️ Suggested index addition
     op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False)
+    op.create_index(op.f('ix_password_reset_tokens_expires_at'), 'password_reset_tokens', ['expires_at'], unique=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py` around
lines 24 - 36, The `password_reset_tokens` schema already matches the model, but
the cleanup path will be slow without an `expires_at` index. Update the
`create_table` migration for `password_reset_tokens` to add an index on
`expires_at` alongside the existing `op.create_index` for `user_id`, so the
expired-token cleanup job can efficiently filter by `expires_at`. Keep the
change within the Alembic migration that defines `op.create_table` for
`password_reset_tokens`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ai_trading_discipline_copilot/routers/auth.py`:
- Around line 333-341: Remove the temporary debug endpoint exposed by auth.py’s
test_email route before release. Delete the unauthenticated
`@router.post`("/test-email") handler and its direct EmailService.send_email call,
since it hardcodes a personal address and can be abused to send real emails. If
this endpoint must remain for internal use, protect it with admin authentication
and a debug-only feature flag, and remove the temporary docstring marker.
- Line 171: The refresh/logout/session cookie lookups in auth.py are still
hardcoded to "refresh_token" instead of using the shared _REFRESH_COOKIE_NAME
constant. Update the relevant request.cookies.get usages in the refresh_token
handling paths to reference _REFRESH_COOKIE_NAME so settings.cookie_name changes
do not break AuthRouter behavior.

In `@src/ai_trading_discipline_copilot/services/email_service.py`:
- Around line 17-33: The send_email method in EmailService is doing a
synchronous Resend request inside an async function, which blocks the event
loop. Update the EmailService.send_email path to use the async Resend API by
awaiting resend.Emails.send_async with the same payload, and ensure the project
dependency includes the async extra for Resend so the async client is available.

---

Nitpick comments:
In `@alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py`:
- Around line 24-36: The `password_reset_tokens` schema already matches the
model, but the cleanup path will be slow without an `expires_at` index. Update
the `create_table` migration for `password_reset_tokens` to add an index on
`expires_at` alongside the existing `op.create_index` for `user_id`, so the
expired-token cleanup job can efficiently filter by `expires_at`. Keep the
change within the Alembic migration that defines `op.create_table` for
`password_reset_tokens`.

In `@alembic/versions/526b088ddfed_add_email_verification_tokens_table.py`:
- Around line 24-36: The email verification token migration is missing the same
`expires_at` index used by the password reset tokens table, so add an index for
`expires_at` in the `op.create_table` flow for `email_verification_tokens`
alongside the existing `user_id` index. Update the migration so cleanup queries
can efficiently target expired rows, using the existing `op.create_index`
pattern and the `email_verification_tokens` table definition.

In `@src/ai_trading_discipline_copilot/core/config.py`:
- Around line 87-90: The database URL validation in config.py is using a broad
except Exception around TypeAdapter(PostgresDsn).validate_python, which should
be narrowed. Update the try/except in the database URL parsing logic to catch
the specific Pydantic validation error type raised by validate_python, such as
pydantic.ValidationError or pydantic_core.ValidationError, while keeping the
same ValueError re-raise behavior and message formatting.
- Around line 59-68: The new email and frontend config fields in the settings
model are currently accepted without validation, so add basic checks alongside
the existing config validation in the Config class. Ensure `resend_api_key`,
`email_from`, and `frontend_url` cannot be empty or blank, and
normalize/validate `frontend_url` so it does not keep a trailing slash that
would break generated links. Reuse the same settings validation pattern used for
`secret_key`, and keep the checks attached to the relevant fields in
`core/config.py` so startup fails fast on bad env values.

In `@src/ai_trading_discipline_copilot/models/__init__.py`:
- Around line 8-13: The __all__ list in the models package is not sorted,
triggering Ruff RUF022. Reorder the exported names in __all__ in alphabetical
order in the __init__ module so the static analysis rule passes, keeping the
existing symbols User, RefreshToken, PasswordResetToken, and
EmailVerificationToken.

In `@src/ai_trading_discipline_copilot/models/password_reset_token.py`:
- Around line 49-53: The password reset token TTL is hardcoded twice, once in
PasswordResetToken.expires_at and again in
PasswordResetService.create_reset_token, which can drift over time. Extract the
15-minute value into a shared constant such as PASSWORD_RESET_TOKEN_TTL and use
it in both the model default and the service’s token creation logic so they stay
consistent.

In `@src/ai_trading_discipline_copilot/routers/auth.py`:
- Around line 344-457: The password reset email template is duplicated in
send_reset_email_task instead of living in EmailService like the verification
email. Move the HTML/template-building logic into a dedicated
EmailService.send_password_reset_email method, using the same pattern as
send_verification_email, and have send_reset_email_task only prepare inputs and
call that service method. Keep the escaping, subject, and footer/year handling
inside EmailService so email rendering stays centralized and consistent.

In `@src/ai_trading_discipline_copilot/schemas/email_verification.py`:
- Around line 6-9: Align VerifyEmailRequest.token with
ResetPasswordRequest.token by adding the same minimum length validation used for
the shared token format generated by secrets.token_urlsafe(32). Update the
VerifyEmailRequest model in email_verification.py so it rejects too-short
malformed tokens early, keeping its validation consistent with
ResetPasswordRequest and the rest of the token-based flow.

In `@src/ai_trading_discipline_copilot/services/__init__.py`:
- Around line 7-11: The __all__ export list in the services package is not in
the sorted order expected by Ruff (RUF022). Update the __all__ definition in the
__init__ module so the exported names are ordered alphabetically using
isort-style sorting, keeping the existing symbols UserService,
PasswordResetService, and EmailVerificationService in the corrected order.

In `@src/ai_trading_discipline_copilot/services/email_service.py`:
- Around line 42-43: The imports in send_verification_email are being done
locally instead of at module scope. Move the html import and the UTC/datetime
import to the top-level import section in email_service.py so
EmailService.send_verification_email uses the shared module imports rather than
re-importing on every call.
- Around line 26-33: The Resend outbound send in
send_email/send_verification_email has no exception handling, so BackgroundTasks
failures can be swallowed and never surfaced. Wrap the resend.Emails.send call
in try/except inside the email service methods, add structured logging with the
exception details and recipient/subject context, and make sure the failure path
is observable (and optionally retried) instead of silently succeeding. Use the
send_email and send_verification_email symbols to update both call sites
consistently.

In `@src/ai_trading_discipline_copilot/services/email_verification_service.py`:
- Around line 26-203: `EmailVerificationService` is duplicating the same token
lifecycle logic as `PasswordResetService`, so extract the shared behavior into a
reusable `TokenService`-style base/helper that owns `generate_token`,
`hash_token`, token creation/rotation, `validate_token`, and
`cleanup_expired_tokens`. Parameterize the shared logic by the token model,
expiry duration, and log message context, then make `EmailVerificationService`
and `PasswordResetService` delegate to it so only the
email-specific/user-specific pieces remain.

In `@tests/conftest.py`:
- Around line 123-133: The test-only User init hook is masking real default
behavior by forcing is_verified=True for every User() unless explicitly passed.
Remove the blanket event listener in conftest and make is_verified’s intended
default explicit in the User model/column definition (or update fixtures to pass
it where needed) so production and tests behave consistently. Also move the
sqlalchemy event and User imports into the top-level import section of conftest
for readability.

In `@tests/test_password_reset.py`:
- Around line 25-37: The test_user fixture duplicates the user creation logic
already present in unverified_user, so extract the shared setup into a reusable
parametrized user factory in conftest.py. Update both test_password_reset and
test_email_verification to use that shared fixture/factory, keeping only the
differing fields like username, email, and is_verified in the per-test
parameters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d2104d42-d812-4c96-91fc-c032b62565d5

📥 Commits

Reviewing files that changed from the base of the PR and between 76161ee and 8ee83ac.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • alembic/versions/3ead82c29db6_add_password_reset_tokens_table.py
  • alembic/versions/526b088ddfed_add_email_verification_tokens_table.py
  • docker-compose.yml
  • pyproject.toml
  • src/ai_trading_discipline_copilot/core/config.py
  • src/ai_trading_discipline_copilot/models/__init__.py
  • src/ai_trading_discipline_copilot/models/email_verification_token.py
  • src/ai_trading_discipline_copilot/models/password_reset_token.py
  • src/ai_trading_discipline_copilot/models/user.py
  • src/ai_trading_discipline_copilot/routers/auth.py
  • src/ai_trading_discipline_copilot/schemas/__init__.py
  • src/ai_trading_discipline_copilot/schemas/email_verification.py
  • src/ai_trading_discipline_copilot/schemas/password_reset.py
  • src/ai_trading_discipline_copilot/services/__init__.py
  • src/ai_trading_discipline_copilot/services/auth_service.py
  • src/ai_trading_discipline_copilot/services/email_service.py
  • src/ai_trading_discipline_copilot/services/email_verification_service.py
  • src/ai_trading_discipline_copilot/services/password_reset_service.py
  • src/ai_trading_discipline_copilot/services/user_service.py
  • tests/conftest.py
  • tests/test_email_verification.py
  • tests/test_password_reset.py

Comment thread src/ai_trading_discipline_copilot/routers/auth.py Outdated
Comment thread src/ai_trading_discipline_copilot/routers/auth.py Outdated
Comment thread src/ai_trading_discipline_copilot/services/email_service.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ai_trading_discipline_copilot/routers/auth.py (1)

113-123: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Drop the token-persistence concern; add failure logging around the background email send.

create_verification_token() already commits before returning, so the token is durable here. The remaining issue is that background_tasks.add_task(EmailService.send_verification_email, ...) has no local error handling, so a Resend failure can still leave the user with no verification email and no application-level signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ai_trading_discipline_copilot/routers/auth.py` around lines 113 - 123,
The verification token persistence concern can be ignored here because
create_verification_token already commits; the real gap is that the email send
via background_tasks.add_task in the auth.register flow has no failure logging.
Add local error handling around the EmailService.send_verification_email
background task setup/dispatch path so any Resend/send failure is captured and
logged with user/email context, while keeping the existing registration and
token creation flow in place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/ai_trading_discipline_copilot/routers/auth.py`:
- Around line 113-123: The verification token persistence concern can be ignored
here because create_verification_token already commits; the real gap is that the
email send via background_tasks.add_task in the auth.register flow has no
failure logging. Add local error handling around the
EmailService.send_verification_email background task setup/dispatch path so any
Resend/send failure is captured and logged with user/email context, while
keeping the existing registration and token creation flow in place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 708eca42-3d50-427c-bb18-dca62e7141b3

📥 Commits

Reviewing files that changed from the base of the PR and between 8ee83ac and db32fd8.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • pyproject.toml
  • src/ai_trading_discipline_copilot/routers/auth.py
  • src/ai_trading_discipline_copilot/services/email_service.py
  • tests/conftest.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • pyproject.toml
  • src/ai_trading_discipline_copilot/services/email_service.py
  • tests/conftest.py

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants