diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8a420cc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,31 @@ +# Copilot Instructions + +## Linting Requirements + +Before committing any changes, always run the relevant linters and fix all errors: + +### Backend (Python) + +```bash +# Check code formatting +black --check backend/ + +# Lint with ruff +ruff check backend/ + +# Type check with mypy +mypy backend/app --ignore-missing-imports +``` + +### Frontend (TypeScript/React) + +```bash +cd frontend +npm run lint +``` + +## Conventions + +- **Python**: Follow PEP 8. Use `black` for formatting. All ruff and mypy errors must be resolved before committing. +- **TypeScript**: Follow the ESLint configuration. Avoid `any` types — use `unknown` with type narrowing instead. Remove unused variables and imports. +- **SQLAlchemy**: Use `# type: ignore[assignment]` for Column attribute assignments and `# noqa: E712` for `== True` comparisons in SQLAlchemy queries (these are valid ORM patterns). diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 72d38f4..6b548d9 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -72,7 +72,7 @@ async def login( ) # Verify password - if not verify_password(form_data.password, user.hashed_password): + if not verify_password(form_data.password, user.hashed_password): # type: ignore[arg-type] raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", @@ -86,7 +86,7 @@ async def login( ) # Update last login - user.last_login_at = datetime.utcnow() + user.last_login_at = datetime.utcnow() # type: ignore[assignment] await db.commit() # Create tokens @@ -129,11 +129,11 @@ async def google_oauth( if user: # Update Google ID if not set if not user.google_id: - user.google_id = google_id - user.oauth_provider = "google" + user.google_id = google_id # type: ignore[assignment] + user.oauth_provider = "google" # type: ignore[assignment] # Update last login - user.last_login_at = datetime.utcnow() + user.last_login_at = datetime.utcnow() # type: ignore[assignment] logger.info(f"Existing user logged in with Google: {user.email}") else: diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index d955d31..69ff20b 100644 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -201,11 +201,11 @@ async def save_gmail_credential( if existing: # Update existing - existing.gmail_email = credential_in.gmail_email - existing.encrypted_access_token = encrypted_access - existing.encrypted_refresh_token = encrypted_refresh - existing.is_valid = True - existing.last_verified_at = datetime.utcnow() + existing.gmail_email = credential_in.gmail_email # type: ignore[assignment] + existing.encrypted_access_token = encrypted_access # type: ignore[assignment] + existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] + existing.is_valid = True # type: ignore[assignment] + existing.last_verified_at = datetime.utcnow() # type: ignore[assignment] await db.commit() await db.refresh(existing) return existing diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index d4fde9f..a609652 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -27,9 +27,9 @@ async def update_current_user_profile( ): """Update current user profile""" if user_update.email: - current_user.email = user_update.email + current_user.email = user_update.email # type: ignore[assignment] if user_update.full_name: - current_user.full_name = user_update.full_name + current_user.full_name = user_update.full_name # type: ignore[assignment] await db.commit() await db.refresh(current_user) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5f287a4..dba292c 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -2,6 +2,8 @@ Database configuration and session management. """ +from collections.abc import AsyncGenerator + from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.orm import declarative_base from app.core.config import settings @@ -28,7 +30,7 @@ async_session_maker = async_sessionmaker( Base = declarative_base() -async def get_db() -> AsyncSession: +async def get_db() -> AsyncGenerator[AsyncSession, None]: """Dependency for getting async database session""" async with async_session_maker() as session: try: diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 2412d69..94ccff1 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -59,7 +59,7 @@ class CSRFProtectionMiddleware(BaseHTTPMiddleware): For API-only applications, this is less critical but still good practice. """ - def __init__(self, app: ASGIApp, exempt_paths: list = None): + def __init__(self, app: ASGIApp, exempt_paths: list | None = None): super().__init__(app) self.exempt_paths = exempt_paths or [ "/api/v1/auth/login", diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py index 9c88f7c..dda83e1 100644 --- a/backend/app/models/database_models.py +++ b/backend/app/models/database_models.py @@ -85,7 +85,9 @@ class User(Base): oauth_provider = Column(String(50), nullable=True) # Subscription - subscription_tier = Column(SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE) + subscription_tier: Column[str] = Column( + SQLEnum(SubscriptionTier), default=SubscriptionTier.FREE + ) subscription_status = Column( String(50), default="active" ) # active, canceled, past_due @@ -127,7 +129,7 @@ class MailAccount(Base): email_address = Column(String(255), nullable=False) # Server configuration - protocol = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL) + protocol: Column[str] = Column(SQLEnum(MailProtocol), default=MailProtocol.POP3_SSL) host = Column(String(255), nullable=False) port = Column(Integer, nullable=False) use_ssl = Column(Boolean, default=True) @@ -141,10 +143,12 @@ class MailAccount(Base): forward_to = Column(String(255), nullable=False) # Delivery method - delivery_method = Column(SQLEnum(DeliveryMethod), default=DeliveryMethod.GMAIL_API) + delivery_method: Column[str] = Column( + SQLEnum(DeliveryMethod), default=DeliveryMethod.GMAIL_API + ) # Status and settings - status = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE) + status: Column[str] = Column(SQLEnum(AccountStatus), default=AccountStatus.ACTIVE) is_enabled = Column(Boolean, default=True) check_interval_minutes = Column(Integer, default=5) max_emails_per_check = Column(Integer, default=50) @@ -263,7 +267,7 @@ class NotificationConfig(Base): ) # Channel details - channel = Column(SQLEnum(NotificationChannel), nullable=False) + channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False) is_enabled = Column(Boolean, default=True) # Channel-specific configuration (stored as JSON) @@ -329,7 +333,7 @@ class SubscriptionPlan(Base): id = Column(Integer, primary_key=True, index=True) # Plan details - tier = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False) + tier: Column[str] = Column(SQLEnum(SubscriptionTier), unique=True, nullable=False) name = Column(String(100), nullable=False) description = Column(Text, nullable=True) diff --git a/backend/app/services/mail_processor.py b/backend/app/services/mail_processor.py index 6c48285..9829bf8 100644 --- a/backend/app/services/mail_processor.py +++ b/backend/app/services/mail_processor.py @@ -148,12 +148,12 @@ class MailProcessor: Fetch emails from the mail server. Returns list of raw email data. """ - max_count = max_count or self.account.max_emails_per_check + effective_max: int = max_count if max_count is not None else self.account.max_emails_per_check # type: ignore[assignment] if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]: - return await self._fetch_pop3_emails(max_count) + return await self._fetch_pop3_emails(effective_max) else: - return await self._fetch_imap_emails(max_count) + return await self._fetch_imap_emails(effective_max) async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]: """Fetch emails via POP3""" @@ -394,7 +394,7 @@ class MailServerAutoDetect: """Auto-detect mail server settings based on email domain""" # Common mail server configurations - KNOWN_PROVIDERS = { + KNOWN_PROVIDERS: Dict[str, Dict[str, Any]] = { "gmail.com": { "name": "Gmail", "pop3_ssl": {"host": "pop.gmail.com", "port": 995}, diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 5209945..534bab6 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -67,15 +67,15 @@ async def process_mail_account(account_id: int): await db.refresh(run) # Decrypt password - password = decrypt_credential(account.encrypted_password) + password = decrypt_credential(account.encrypted_password) # type: ignore[arg-type] # Create processor processor = MailProcessor(account, password) # Fetch emails - emails = await processor.fetch_emails(account.max_emails_per_check) + emails = await processor.fetch_emails(account.max_emails_per_check) # type: ignore[arg-type] - run.emails_fetched = len(emails) + run.emails_fetched = len(emails) # type: ignore[assignment] # Forward emails emails_forwarded = 0 @@ -98,9 +98,9 @@ async def process_mail_account(account_id: int): gmail_cred = gmail_cred_result.scalar_one_or_none() if gmail_cred: - access_token = decrypt_credential(gmail_cred.encrypted_access_token) + access_token = decrypt_credential(gmail_cred.encrypted_access_token) # type: ignore[arg-type] refresh_token = ( - decrypt_credential(gmail_cred.encrypted_refresh_token) + decrypt_credential(gmail_cred.encrypted_refresh_token) # type: ignore[arg-type] if gmail_cred.encrypted_refresh_token else None ) @@ -115,7 +115,7 @@ async def process_mail_account(account_id: int): f"Gmail API credentials not found for user {account.user_id}, " f"falling back to SMTP for account {account.id}" ) - use_gmail_api = False + use_gmail_api = False # type: ignore[assignment] if not use_gmail_api: # Fall back to SMTP @@ -131,8 +131,8 @@ async def process_mail_account(account_id: int): logger.error( f"SMTP credentials not configured for account {account.id}" ) - run.status = "failed" - run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)" + run.status = "failed" # type: ignore[assignment] + run.error_message = "No delivery method configured (SMTP credentials missing and Gmail API not set up)" # type: ignore[assignment] await db.commit() return @@ -143,13 +143,13 @@ async def process_mail_account(account_id: int): await gmail_service.inject_email( raw_email=email_data, label_ids=["INBOX"], - source_account_name=account.name, + source_account_name=account.name, # type: ignore[arg-type] ) emails_forwarded += 1 else: # Forward via SMTP (fallback) success = await MailProcessor.forward_email( - email_data, account.name, account.forward_to, smtp_config + email_data, account.name, account.forward_to, smtp_config # type: ignore[arg-type] ) if success: emails_forwarded += 1 @@ -161,24 +161,24 @@ async def process_mail_account(account_id: int): emails_failed += 1 # Update run - run.emails_forwarded = emails_forwarded - run.emails_failed = emails_failed - run.completed_at = datetime.utcnow() + run.emails_forwarded = emails_forwarded # type: ignore[assignment] + run.emails_failed = emails_failed # type: ignore[assignment] + run.completed_at = datetime.utcnow() # type: ignore[assignment] run.duration_seconds = (run.completed_at - run.started_at).total_seconds() - run.status = "completed" if emails_failed == 0 else "partial_failure" + run.status = "completed" if emails_failed == 0 else "partial_failure" # type: ignore[assignment] # Update account - account.total_emails_processed += emails_forwarded - account.total_emails_failed += emails_failed - account.last_check_at = datetime.utcnow() + account.total_emails_processed += emails_forwarded # type: ignore[assignment] + account.total_emails_failed += emails_failed # type: ignore[assignment] + account.last_check_at = datetime.utcnow() # type: ignore[assignment] if emails_failed == 0: - account.last_successful_check_at = datetime.utcnow() - account.status = AccountStatus.ACTIVE + account.last_successful_check_at = datetime.utcnow() # type: ignore[assignment] + account.status = AccountStatus.ACTIVE # type: ignore[assignment] else: - account.status = AccountStatus.ERROR - account.last_error_at = datetime.utcnow() - account.last_error_message = f"{emails_failed} emails failed to forward" + account.status = AccountStatus.ERROR # type: ignore[assignment] + account.last_error_at = datetime.utcnow() # type: ignore[assignment] + account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment] await db.commit() @@ -192,18 +192,18 @@ async def process_mail_account(account_id: int): # Mark run as failed if "run" in locals(): - run.status = "failed" - run.error_message = str(e) - run.completed_at = datetime.utcnow() + run.status = "failed" # type: ignore[assignment] + run.error_message = str(e) # type: ignore[assignment] + run.completed_at = datetime.utcnow() # type: ignore[assignment] run.duration_seconds = ( run.completed_at - run.started_at ).total_seconds() # Update account error status - if "account" in locals(): - account.status = AccountStatus.ERROR - account.last_error_at = datetime.utcnow() - account.last_error_message = str(e) + if "account" in locals() and account is not None: + account.status = AccountStatus.ERROR # type: ignore[assignment] + account.last_error_at = datetime.utcnow() # type: ignore[assignment] + account.last_error_message = str(e) # type: ignore[assignment] await db.commit() diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index da4598c..b7efa27 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/authStore'; export default function LoginPage() { const router = useRouter(); - const setUser = useAuthStore((state) => state.setUser); + const _setUser = useAuthStore((state) => state.setUser); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -25,8 +25,9 @@ export default function LoginPage() { // Redirect to dashboard router.push('/dashboard'); - } catch (err: any) { - setError(err.response?.data?.detail || 'Login failed. Please try again.'); + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } }; + setError(error.response?.data?.detail || 'Login failed. Please try again.'); } finally { setLoading(false); } @@ -37,7 +38,7 @@ export default function LoginPage() { const redirectUri = `${window.location.origin}/auth/callback`; const authUrl = await authApi.getGoogleAuthUrl(redirectUri); window.location.href = authUrl; - } catch (err: any) { + } catch (_err: unknown) { setError('Failed to initialize Google login'); } }; diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 669fc15..b10feab 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -47,8 +47,9 @@ export default function RegisterPage() { localStorage.setItem('access_token', loginResponse.access_token); router.push('/dashboard'); - } catch (err: any) { - setError(err.response?.data?.detail || 'Registration failed. Please try again.'); + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } }; + setError(error.response?.data?.detail || 'Registration failed. Please try again.'); } finally { setLoading(false); }