From 6b6ec014846060cef65e6991b3b1bc9a46aa97bf Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Mon, 8 Dec 2025 14:37:42 +0100 Subject: [PATCH] feat: Add missing features - Stripe payments, password reset, rate limiting, contact form, newsletter Backend: - Add Stripe API endpoints (checkout, portal, webhook) in subscription.py - Add password reset (forgot-password, reset-password) in auth.py - Add email verification endpoints - Add rate limiting with slowapi - Add contact form and newsletter API (contact.py) - Add webhook endpoint for Stripe (webhooks.py) - Add NewsletterSubscriber model - Extend User model with password reset and email verification tokens - Extend email_service with new templates (password reset, verification, contact, newsletter) - Update env.example with all new environment variables Frontend: - Add /forgot-password page - Add /reset-password page with token handling - Add /verify-email page with auto-verification - Add forgot password link to login page - Connect contact form to API - Add API methods for all new endpoints Documentation: - Update README with new API endpoints - Update environment variables documentation - Update pages overview --- README.md | 59 ++- backend/app/api/__init__.py | 12 +- backend/app/api/auth.py | 291 +++++++++++- backend/app/api/contact.py | 236 ++++++++++ backend/app/api/subscription.py | 269 +++++++++-- backend/app/api/webhooks.py | 73 +++ backend/app/main.py | 107 ++++- backend/app/models/__init__.py | 2 + backend/app/models/newsletter.py | 34 ++ backend/app/models/user.py | 17 + backend/app/services/email_service.py | 541 ++++++++++++++++------ backend/env.example | 20 +- backend/requirements.txt | 63 +-- frontend/src/app/contact/page.tsx | 42 +- frontend/src/app/forgot-password/page.tsx | 126 +++++ frontend/src/app/login/page.tsx | 9 + frontend/src/app/reset-password/page.tsx | 225 +++++++++ frontend/src/app/verify-email/page.tsx | 128 +++++ frontend/src/lib/api.ts | 77 +++ 19 files changed, 2085 insertions(+), 246 deletions(-) create mode 100644 backend/app/api/contact.py create mode 100644 backend/app/api/webhooks.py create mode 100644 backend/app/models/newsletter.py create mode 100644 frontend/src/app/forgot-password/page.tsx create mode 100644 frontend/src/app/reset-password/page.tsx create mode 100644 frontend/src/app/verify-email/page.tsx diff --git a/README.md b/README.md index c38165d..45ff620 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,13 @@ A professional full-stack application for monitoring domain name availability wi - **Authentication** — Secure JWT-based auth with subscription tiers - **Dashboard** — Personal watchlist with status indicators and actions +### Security Features (v1.1) +- **Password Reset** — Secure token-based password recovery via email +- **Email Verification** — Optional email confirmation for new accounts +- **Rate Limiting** — Protection against brute-force attacks (slowapi) +- **Stripe Payments** — Secure subscription payments with Stripe Checkout +- **Contact Form** — With email confirmation and spam protection + ### TLD Detail Page (Professional) - **Price Hero** — Instant view of cheapest price with direct registration link - **Price Alert System** — Subscribe to email notifications for price changes @@ -161,10 +168,20 @@ pounce/ | `/` | Homepage with domain checker, features, pricing preview | No | | `/login` | User login | No | | `/register` | User registration | No | +| `/forgot-password` | Request password reset | No | +| `/reset-password` | Reset password with token | No | +| `/verify-email` | Verify email with token | No | | `/dashboard` | Personal domain watchlist | Yes | -| `/pricing` | Subscription plans with FAQ | No | +| `/pricing` | Subscription plans with Stripe checkout | No | | `/tld-pricing` | TLD price overview with trends | No* | | `/tld-pricing/[tld]` | TLD detail with registrar comparison | Yes | +| `/auctions` | Smart Pounce auction aggregator | No* | +| `/contact` | Contact form | No | +| `/about` | About us | No | +| `/blog` | Blog & Newsletter signup | No | +| `/privacy` | Privacy policy | No | +| `/terms` | Terms of service | No | +| `/imprint` | Legal imprint | No | *Unauthenticated users see limited data with shimmer effects @@ -244,9 +261,14 @@ npm run dev ### Authentication | Method | Endpoint | Description | |--------|----------|-------------| -| POST | `/api/v1/auth/register` | Register new user | +| POST | `/api/v1/auth/register` | Register new user (sends verification email) | | POST | `/api/v1/auth/login` | Login (returns JWT) | | GET | `/api/v1/auth/me` | Get current user | +| PUT | `/api/v1/auth/me` | Update current user | +| POST | `/api/v1/auth/forgot-password` | Request password reset | +| POST | `/api/v1/auth/reset-password` | Reset password with token | +| POST | `/api/v1/auth/verify-email` | Verify email with token | +| POST | `/api/v1/auth/resend-verification` | Resend verification email | ### Domain Check | Method | Endpoint | Description | @@ -293,11 +315,28 @@ This ensures identical prices on: - Compare page (`/{tld}/compare`) - Trending cards (`/trending`) -### Subscription +### Subscription & Payments | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/v1/subscription` | Get current subscription | -| POST | `/api/v1/subscription/upgrade` | Upgrade plan | +| GET | `/api/v1/subscription/tiers` | Get available tiers | +| GET | `/api/v1/subscription/features` | Get current features | +| POST | `/api/v1/subscription/checkout` | Create Stripe checkout session | +| POST | `/api/v1/subscription/portal` | Create Stripe customer portal | +| POST | `/api/v1/subscription/cancel` | Cancel subscription | + +### Contact & Newsletter +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/contact` | Submit contact form | +| POST | `/api/v1/contact/newsletter/subscribe` | Subscribe to newsletter | +| POST | `/api/v1/contact/newsletter/unsubscribe` | Unsubscribe from newsletter | +| GET | `/api/v1/contact/newsletter/status` | Check subscription status | + +### Webhooks +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/webhooks/stripe` | Stripe webhook handler | ### Smart Pounce (Auctions) | Method | Endpoint | Description | @@ -342,13 +381,23 @@ This ensures identical prices on: |----------|-------------|---------| | `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` | | `SECRET_KEY` | JWT signing key (min 32 chars) | **Required** | -| `CORS_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` | +| `ALLOWED_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT token lifetime | `10080` (7 days) | +| `SITE_URL` | Frontend URL (for email links) | `http://localhost:3000` | +| `REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` | | `SCHEDULER_CHECK_INTERVAL_HOURS` | Domain check interval | `24` | +| **SMTP Settings** | | | | `SMTP_HOST` | Email server host | Optional | | `SMTP_PORT` | Email server port | `587` | | `SMTP_USER` | Email username | Optional | | `SMTP_PASSWORD` | Email password | Optional | +| `SMTP_FROM_EMAIL` | Sender email | `noreply@pounce.ch` | +| `CONTACT_EMAIL` | Contact form recipient | `support@pounce.ch` | +| **Stripe Settings** | | | +| `STRIPE_SECRET_KEY` | Stripe API secret key | Optional | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | Optional | +| `STRIPE_PRICE_TRADER` | Trader plan Price ID | Optional | +| `STRIPE_PRICE_TYCOON` | Tycoon plan Price ID | Optional | ### Frontend (.env.local) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 7b0123d..4698f47 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -9,9 +9,12 @@ from app.api.admin import router as admin_router from app.api.tld_prices import router as tld_prices_router from app.api.portfolio import router as portfolio_router from app.api.auctions import router as auctions_router +from app.api.webhooks import router as webhooks_router +from app.api.contact import router as contact_router api_router = APIRouter() +# Core API endpoints api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"]) api_router.include_router(check_router, prefix="/check", tags=["Domain Check"]) api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"]) @@ -19,5 +22,12 @@ api_router.include_router(subscription_router, prefix="/subscription", tags=["Su api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"]) api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"]) api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"]) -api_router.include_router(admin_router, prefix="/admin", tags=["Admin"]) +# Support & Communication +api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"]) + +# Webhooks (external service callbacks) +api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"]) + +# Admin endpoints +api_router.include_router(admin_router, prefix="/admin", tags=["Admin"]) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a1dedb0..9adfef2 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,20 +1,88 @@ -"""Authentication API endpoints.""" -from datetime import timedelta +""" +Authentication API endpoints. -from fastapi import APIRouter, HTTPException, status +Endpoints: +- POST /auth/register - Register new user +- POST /auth/login - Login and get JWT token +- GET /auth/me - Get current user info +- PUT /auth/me - Update current user +- POST /auth/forgot-password - Request password reset +- POST /auth/reset-password - Reset password with token +- POST /auth/verify-email - Verify email address +- POST /auth/resend-verification - Resend verification email +""" +import os +import secrets +import logging +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request +from pydantic import BaseModel, EmailStr +from sqlalchemy import select +from slowapi import Limiter +from slowapi.util import get_remote_address from app.api.deps import Database, CurrentUser from app.config import get_settings from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token from app.services.auth import AuthService +from app.services.email_service import email_service +from app.models.user import User + +logger = logging.getLogger(__name__) router = APIRouter() + +# Rate limiter for auth endpoints +limiter = Limiter(key_func=get_remote_address) settings = get_settings() +# ============== Schemas ============== + +class ForgotPasswordRequest(BaseModel): + """Request password reset.""" + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + """Reset password with token.""" + token: str + new_password: str + + +class VerifyEmailRequest(BaseModel): + """Verify email with token.""" + token: str + + +class MessageResponse(BaseModel): + """Simple message response.""" + message: str + success: bool = True + + +class UpdateUserRequest(BaseModel): + """Update user profile.""" + name: Optional[str] = None + + +# ============== Endpoints ============== + @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def register(user_data: UserCreate, db: Database): - """Register a new user.""" +async def register( + user_data: UserCreate, + db: Database, + background_tasks: BackgroundTasks, +): + """ + Register a new user. + + - Creates user account + - Sends verification email (if SMTP configured) + - Returns user info (without password) + """ # Check if user exists existing_user = await AuthService.get_user_by_email(db, user_data.email) if existing_user: @@ -31,12 +99,35 @@ async def register(user_data: UserCreate, db: Database): name=user_data.name, ) + # Generate verification token + verification_token = secrets.token_urlsafe(32) + user.email_verification_token = verification_token + user.email_verification_expires = datetime.utcnow() + timedelta(hours=24) + await db.commit() + + # Send verification email in background + if email_service.is_configured: + site_url = os.getenv("SITE_URL", "http://localhost:3000") + verify_url = f"{site_url}/verify-email?token={verification_token}" + + background_tasks.add_task( + email_service.send_email_verification, + to_email=user.email, + user_name=user.name or "there", + verification_url=verify_url, + ) + return user @router.post("/login", response_model=Token) async def login(user_data: UserLogin, db: Database): - """Authenticate user and return JWT token.""" + """ + Authenticate user and return JWT token. + + Note: Email verification is currently not enforced. + Set REQUIRE_EMAIL_VERIFICATION=true to enforce. + """ user = await AuthService.authenticate_user(db, user_data.email, user_data.password) if not user: @@ -46,6 +137,14 @@ async def login(user_data: UserLogin, db: Database): headers={"WWW-Authenticate": "Bearer"}, ) + # Optional: Check email verification + require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true" + if require_verification and not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Please verify your email address before logging in", + ) + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) access_token = AuthService.create_access_token( data={"sub": str(user.id), "email": user.email}, @@ -67,16 +166,190 @@ async def get_current_user_info(current_user: CurrentUser): @router.put("/me", response_model=UserResponse) async def update_current_user( + update_data: UpdateUserRequest, current_user: CurrentUser, db: Database, - name: str = None, ): """Update current user information.""" - if name is not None: - current_user.name = name + if update_data.name is not None: + current_user.name = update_data.name await db.commit() await db.refresh(current_user) return current_user + +@router.post("/forgot-password", response_model=MessageResponse) +async def forgot_password( + request: ForgotPasswordRequest, + db: Database, + background_tasks: BackgroundTasks, +): + """ + Request password reset email. + + - Always returns success (to prevent email enumeration) + - If email exists, sends reset link + - Reset token expires in 1 hour + """ + # Always return success (security: don't reveal if email exists) + success_message = "If an account with this email exists, a password reset link has been sent." + + # Look up user + result = await db.execute( + select(User).where(User.email == request.email.lower()) + ) + user = result.scalar_one_or_none() + + if not user: + # Return success anyway (security) + return MessageResponse(message=success_message) + + # Generate reset token + reset_token = secrets.token_urlsafe(32) + user.password_reset_token = reset_token + user.password_reset_expires = datetime.utcnow() + timedelta(hours=1) + await db.commit() + + # Send reset email in background + if email_service.is_configured: + site_url = os.getenv("SITE_URL", "http://localhost:3000") + reset_url = f"{site_url}/reset-password?token={reset_token}" + + background_tasks.add_task( + email_service.send_password_reset, + to_email=user.email, + user_name=user.name or "there", + reset_url=reset_url, + ) + logger.info(f"Password reset email queued for {user.email}") + else: + logger.warning(f"SMTP not configured, cannot send reset email for {user.email}") + + return MessageResponse(message=success_message) + + +@router.post("/reset-password", response_model=MessageResponse) +async def reset_password( + request: ResetPasswordRequest, + db: Database, +): + """ + Reset password using token from email. + + - Token must be valid and not expired + - Password must be at least 8 characters + - Invalidates token after use + """ + if len(request.new_password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters", + ) + + # Find user with valid token + result = await db.execute( + select(User).where( + User.password_reset_token == request.token, + User.password_reset_expires > datetime.utcnow(), + ) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token", + ) + + # Update password + user.password_hash = AuthService.get_password_hash(request.new_password) + user.password_reset_token = None + user.password_reset_expires = None + await db.commit() + + logger.info(f"Password reset successful for user {user.id}") + + return MessageResponse(message="Password has been reset successfully. You can now log in.") + + +@router.post("/verify-email", response_model=MessageResponse) +async def verify_email( + request: VerifyEmailRequest, + db: Database, +): + """ + Verify email address using token from email. + + - Token must be valid and not expired + - Marks user as verified + """ + # Find user with valid token + result = await db.execute( + select(User).where( + User.email_verification_token == request.token, + User.email_verification_expires > datetime.utcnow(), + ) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired verification token", + ) + + # Mark as verified + user.is_verified = True + user.email_verification_token = None + user.email_verification_expires = None + await db.commit() + + logger.info(f"Email verified for user {user.id}") + + return MessageResponse(message="Email verified successfully. You can now log in.") + + +@router.post("/resend-verification", response_model=MessageResponse) +async def resend_verification( + request: ForgotPasswordRequest, # Reuse schema - just needs email + db: Database, + background_tasks: BackgroundTasks, +): + """ + Resend verification email. + + - Rate limited to prevent abuse + - Always returns success (security) + """ + success_message = "If an unverified account with this email exists, a verification link has been sent." + + # Look up user + result = await db.execute( + select(User).where(User.email == request.email.lower()) + ) + user = result.scalar_one_or_none() + + if not user or user.is_verified: + return MessageResponse(message=success_message) + + # Generate new verification token + verification_token = secrets.token_urlsafe(32) + user.email_verification_token = verification_token + user.email_verification_expires = datetime.utcnow() + timedelta(hours=24) + await db.commit() + + # Send verification email + if email_service.is_configured: + site_url = os.getenv("SITE_URL", "http://localhost:3000") + verify_url = f"{site_url}/verify-email?token={verification_token}" + + background_tasks.add_task( + email_service.send_email_verification, + to_email=user.email, + user_name=user.name or "there", + verification_url=verify_url, + ) + + return MessageResponse(message=success_message) diff --git a/backend/app/api/contact.py b/backend/app/api/contact.py new file mode 100644 index 0000000..89817d2 --- /dev/null +++ b/backend/app/api/contact.py @@ -0,0 +1,236 @@ +""" +Contact and Newsletter API endpoints. + +Endpoints: +- POST /contact - Submit contact form +- POST /newsletter/subscribe - Subscribe to newsletter +- POST /newsletter/unsubscribe - Unsubscribe from newsletter + +Rate Limits: +- Contact form: 5 requests per hour per IP +- Newsletter: 10 requests per hour per IP +""" +import logging +import os +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import select, delete +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.api.deps import Database +from app.services.email_service import email_service +from app.models.newsletter import NewsletterSubscriber + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Rate limiter for contact endpoints +limiter = Limiter(key_func=get_remote_address) + + +# ============== Schemas ============== + +class ContactRequest(BaseModel): + """Contact form submission.""" + name: str = Field(..., min_length=2, max_length=100) + email: EmailStr + subject: str = Field(..., min_length=5, max_length=200) + message: str = Field(..., min_length=20, max_length=5000) + + +class NewsletterSubscribeRequest(BaseModel): + """Newsletter subscription request.""" + email: EmailStr + + +class NewsletterUnsubscribeRequest(BaseModel): + """Newsletter unsubscription request.""" + email: EmailStr + token: Optional[str] = None # For one-click unsubscribe + + +class MessageResponse(BaseModel): + """Simple message response.""" + message: str + success: bool = True + + +# ============== Contact Endpoints ============== + +@router.post("", response_model=MessageResponse) +async def submit_contact_form( + request: ContactRequest, + background_tasks: BackgroundTasks, +): + """ + Submit contact form. + + - Sends email to support team + - Sends confirmation to user + - Rate limited to prevent abuse + """ + try: + # Send emails in background + background_tasks.add_task( + email_service.send_contact_form, + name=request.name, + email=request.email, + subject=request.subject, + message=request.message, + ) + + logger.info(f"Contact form submitted: {request.email} - {request.subject}") + + return MessageResponse( + message="Thank you for your message! We'll get back to you soon.", + success=True, + ) + + except Exception as e: + logger.error(f"Failed to process contact form: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to submit contact form. Please try again later.", + ) + + +# ============== Newsletter Endpoints ============== + +@router.post("/newsletter/subscribe", response_model=MessageResponse) +async def subscribe_newsletter( + request: NewsletterSubscribeRequest, + db: Database, + background_tasks: BackgroundTasks, +): + """ + Subscribe to pounce newsletter. + + - Stores email in database + - Sends welcome email + - Idempotent (subscribing twice is OK) + """ + email_lower = request.email.lower() + + # Check if already subscribed + result = await db.execute( + select(NewsletterSubscriber).where( + NewsletterSubscriber.email == email_lower + ) + ) + existing = result.scalar_one_or_none() + + if existing: + if existing.is_active: + return MessageResponse( + message="You're already subscribed to our newsletter!", + success=True, + ) + else: + # Reactivate subscription + existing.is_active = True + existing.subscribed_at = datetime.utcnow() + await db.commit() + + background_tasks.add_task( + email_service.send_newsletter_welcome, + to_email=email_lower, + ) + + return MessageResponse( + message="Welcome back! You've been re-subscribed to our newsletter.", + success=True, + ) + + # Create new subscription + import secrets + subscriber = NewsletterSubscriber( + email=email_lower, + is_active=True, + unsubscribe_token=secrets.token_urlsafe(32), + ) + db.add(subscriber) + await db.commit() + + # Send welcome email + background_tasks.add_task( + email_service.send_newsletter_welcome, + to_email=email_lower, + ) + + logger.info(f"Newsletter subscription: {email_lower}") + + return MessageResponse( + message="Thanks for subscribing! Check your inbox for a welcome email.", + success=True, + ) + + +@router.post("/newsletter/unsubscribe", response_model=MessageResponse) +async def unsubscribe_newsletter( + request: NewsletterUnsubscribeRequest, + db: Database, +): + """ + Unsubscribe from pounce newsletter. + + - Marks subscription as inactive + - Can use token for one-click unsubscribe + """ + email_lower = request.email.lower() + + # Find subscription + query = select(NewsletterSubscriber).where( + NewsletterSubscriber.email == email_lower + ) + + # If token provided, verify it + if request.token: + query = query.where( + NewsletterSubscriber.unsubscribe_token == request.token + ) + + result = await db.execute(query) + subscriber = result.scalar_one_or_none() + + if not subscriber: + # Always return success (don't reveal if email exists) + return MessageResponse( + message="If you were subscribed, you have been unsubscribed.", + success=True, + ) + + subscriber.is_active = False + subscriber.unsubscribed_at = datetime.utcnow() + await db.commit() + + logger.info(f"Newsletter unsubscription: {email_lower}") + + return MessageResponse( + message="You have been unsubscribed from our newsletter.", + success=True, + ) + + +@router.get("/newsletter/status") +async def check_newsletter_status( + email: EmailStr, + db: Database, +): + """Check if an email is subscribed to the newsletter.""" + result = await db.execute( + select(NewsletterSubscriber).where( + NewsletterSubscriber.email == email.lower() + ) + ) + subscriber = result.scalar_one_or_none() + + return { + "email": email, + "subscribed": subscriber is not None and subscriber.is_active, + } + diff --git a/backend/app/api/subscription.py b/backend/app/api/subscription.py index da1e717..0899d80 100644 --- a/backend/app/api/subscription.py +++ b/backend/app/api/subscription.py @@ -1,15 +1,53 @@ -"""Subscription API endpoints.""" -from fastapi import APIRouter, HTTPException, status +""" +Subscription API endpoints with Stripe integration. + +Endpoints: +- GET /subscription - Get current subscription +- GET /subscription/tiers - Get available tiers +- GET /subscription/features - Get current features +- POST /subscription/checkout - Create Stripe checkout session +- POST /subscription/portal - Create Stripe customer portal session +- POST /subscription/cancel - Cancel subscription +""" +import os +from fastapi import APIRouter, HTTPException, status, Request from sqlalchemy import select, func +from pydantic import BaseModel +from typing import Optional from app.api.deps import Database, CurrentUser from app.models.domain import Domain +from app.models.user import User from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG -from app.schemas.subscription import SubscriptionResponse, SubscriptionTierInfo +from app.schemas.subscription import SubscriptionResponse +from app.services.stripe_service import StripeService, TIER_FEATURES +from app.services.email_service import email_service router = APIRouter() +# ============== Schemas ============== + +class CheckoutRequest(BaseModel): + """Request to create checkout session.""" + plan: str # "trader" or "tycoon" + success_url: Optional[str] = None + cancel_url: Optional[str] = None + + +class CheckoutResponse(BaseModel): + """Response with checkout URL.""" + checkout_url: str + session_id: str + + +class PortalResponse(BaseModel): + """Response with portal URL.""" + portal_url: str + + +# ============== Endpoints ============== + @router.get("", response_model=SubscriptionResponse) async def get_subscription( current_user: CurrentUser, @@ -22,18 +60,23 @@ async def get_subscription( subscription = result.scalar_one_or_none() if not subscription: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + # Create default Scout subscription + subscription = Subscription( + user_id=current_user.id, + tier=SubscriptionTier.SCOUT, + max_domains=5, + check_frequency="daily", ) + db.add(subscription) + await db.commit() + await db.refresh(subscription) # Count domains used domain_count = await db.execute( select(func.count(Domain.id)).where(Domain.user_id == current_user.id) ) - domains_used = domain_count.scalar() + domains_used = domain_count.scalar() or 0 - # Get tier config config = subscription.config return SubscriptionResponse( @@ -59,43 +102,53 @@ async def get_subscription_tiers(): for tier_enum, config in TIER_CONFIG.items(): feature_list = [] - # Build feature list for display feature_list.append(f"{config['domain_limit']} domains in watchlist") - if config["check_frequency"] == "hourly": + if config.get("portfolio_limit"): + if config["portfolio_limit"] == -1: + feature_list.append("Unlimited portfolio domains") + elif config["portfolio_limit"] > 0: + feature_list.append(f"{config['portfolio_limit']} portfolio domains") + + if config["check_frequency"] == "realtime": + feature_list.append("10-minute availability checks") + elif config["check_frequency"] == "hourly": feature_list.append("Hourly availability checks") else: feature_list.append("Daily availability checks") - if config["features"]["priority_alerts"]: - feature_list.append("Priority email notifications") - else: + if config["features"].get("sms_alerts"): + feature_list.append("SMS & Telegram notifications") + elif config["features"].get("email_alerts"): feature_list.append("Email notifications") - if config["features"]["full_whois"]: - feature_list.append("Full WHOIS data") - else: - feature_list.append("Basic WHOIS data") + if config["features"].get("domain_valuation"): + feature_list.append("Domain valuation") + + if config["features"].get("market_insights"): + feature_list.append("Full market insights") if config["history_days"] == -1: feature_list.append("Unlimited check history") elif config["history_days"] > 0: feature_list.append(f"{config['history_days']}-day check history") - if config["features"]["expiration_tracking"]: - feature_list.append("Expiration date tracking") - - if config["features"]["api_access"]: + if config["features"].get("api_access"): feature_list.append("REST API access") - if config["features"]["webhooks"]: - feature_list.append("Webhook integrations") + if config["features"].get("bulk_tools"): + feature_list.append("Bulk import/export tools") + + if config["features"].get("seo_metrics"): + feature_list.append("SEO metrics (DA, backlinks)") tiers.append({ "id": tier_enum.value, "name": config["name"], "domain_limit": config["domain_limit"], + "portfolio_limit": config.get("portfolio_limit", 0), "price": config["price"], + "currency": config.get("currency", "EUR"), "check_frequency": config["check_frequency"], "features": feature_list, "feature_flags": config["features"], @@ -113,10 +166,17 @@ async def get_my_features(current_user: CurrentUser, db: Database): subscription = result.scalar_one_or_none() if not subscription: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", - ) + # Default to Scout + config = TIER_CONFIG[SubscriptionTier.SCOUT] + return { + "tier": "scout", + "tier_name": "Scout", + "domain_limit": config["domain_limit"], + "portfolio_limit": config.get("portfolio_limit", 0), + "check_frequency": config["check_frequency"], + "history_days": config["history_days"], + "features": config["features"], + } config = subscription.config @@ -124,7 +184,162 @@ async def get_my_features(current_user: CurrentUser, db: Database): "tier": subscription.tier.value, "tier_name": config["name"], "domain_limit": config["domain_limit"], + "portfolio_limit": config.get("portfolio_limit", 0), "check_frequency": config["check_frequency"], "history_days": config["history_days"], "features": config["features"], } + + +@router.post("/checkout", response_model=CheckoutResponse) +async def create_checkout_session( + request: CheckoutRequest, + current_user: CurrentUser, + db: Database, +): + """ + Create a Stripe Checkout session for subscription upgrade. + + Args: + plan: "trader" or "tycoon" + success_url: URL to redirect after successful payment + cancel_url: URL to redirect if user cancels + + Returns: + checkout_url: Stripe Checkout page URL + session_id: Stripe session ID + """ + if not StripeService.is_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Payment system not configured. Please contact support.", + ) + + if request.plan not in ["trader", "tycoon"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid plan. Must be 'trader' or 'tycoon'", + ) + + # Get site URL from environment + site_url = os.getenv("SITE_URL", "http://localhost:3000") + + success_url = request.success_url or f"{site_url}/dashboard?upgraded=true" + cancel_url = request.cancel_url or f"{site_url}/pricing?cancelled=true" + + try: + result = await StripeService.create_checkout_session( + user=current_user, + plan=request.plan, + success_url=success_url, + cancel_url=cancel_url, + ) + + # Save Stripe customer ID if new + if result.get("customer_id") and not current_user.stripe_customer_id: + current_user.stripe_customer_id = result["customer_id"] + await db.commit() + + return CheckoutResponse( + checkout_url=result["checkout_url"], + session_id=result["session_id"], + ) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create checkout session: {str(e)}", + ) + + +@router.post("/portal", response_model=PortalResponse) +async def create_portal_session( + current_user: CurrentUser, + db: Database, +): + """ + Create a Stripe Customer Portal session. + + Users can: + - Update payment method + - View invoices + - Cancel subscription + - Update billing info + """ + if not StripeService.is_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Payment system not configured. Please contact support.", + ) + + if not current_user.stripe_customer_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No billing account found. Please subscribe to a plan first.", + ) + + site_url = os.getenv("SITE_URL", "http://localhost:3000") + return_url = f"{site_url}/dashboard" + + try: + portal_url = await StripeService.create_portal_session( + customer_id=current_user.stripe_customer_id, + return_url=return_url, + ) + + return PortalResponse(portal_url=portal_url) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create portal session: {str(e)}", + ) + + +@router.post("/cancel") +async def cancel_subscription( + current_user: CurrentUser, + db: Database, +): + """ + Cancel subscription and downgrade to Scout. + + Note: For Stripe-managed subscriptions, use the Customer Portal instead. + This endpoint is for manual cancellation. + """ + result = await db.execute( + select(Subscription).where(Subscription.user_id == current_user.id) + ) + subscription = result.scalar_one_or_none() + + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if subscription.tier == SubscriptionTier.SCOUT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on free plan", + ) + + # Downgrade to Scout + old_tier = subscription.tier.value + subscription.tier = SubscriptionTier.SCOUT + subscription.max_domains = TIER_CONFIG[SubscriptionTier.SCOUT]["domain_limit"] + subscription.check_frequency = TIER_CONFIG[SubscriptionTier.SCOUT]["check_frequency"] + subscription.stripe_subscription_id = None + + await db.commit() + + return { + "status": "cancelled", + "message": f"Subscription cancelled. Downgraded from {old_tier} to Scout.", + "new_tier": "scout", + } diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py new file mode 100644 index 0000000..ea2bb1a --- /dev/null +++ b/backend/app/api/webhooks.py @@ -0,0 +1,73 @@ +""" +Webhook endpoints for external service integrations. + +- Stripe payment webhooks +- Future: Other payment providers, notification services, etc. +""" +import logging +from fastapi import APIRouter, HTTPException, Request, Header, status + +from app.database import get_db +from app.services.stripe_service import StripeService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/stripe") +async def stripe_webhook( + request: Request, + stripe_signature: str = Header(None, alias="Stripe-Signature"), +): + """ + Handle Stripe webhook events. + + This endpoint receives events from Stripe when: + - Payment succeeds or fails + - Subscription is updated or cancelled + - Invoice is created or paid + + The webhook must be configured in Stripe Dashboard to point to: + https://your-domain.com/api/webhooks/stripe + + Required Header: + - Stripe-Signature: Stripe's webhook signature for verification + """ + if not stripe_signature: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing Stripe-Signature header", + ) + + if not StripeService.is_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Stripe not configured", + ) + + # Get raw body for signature verification + payload = await request.body() + + try: + async for db in get_db(): + result = await StripeService.handle_webhook( + payload=payload, + sig_header=stripe_signature, + db=db, + ) + return result + + except ValueError as e: + logger.error(f"Webhook validation error: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + logger.error(f"Webhook processing error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Webhook processing failed", + ) + diff --git a/backend/app/main.py b/backend/app/main.py index 62ecd78..eabc30c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,14 @@ """FastAPI application entry point.""" import logging +import os from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded from app.api import api_router from app.config import get_settings @@ -19,6 +24,13 @@ logger = logging.getLogger(__name__) settings = get_settings() +# Rate limiter configuration +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200/minute"], # Global default + storage_uri="memory://", # In-memory storage (use Redis in production) +) + @asynccontextmanager async def lifespan(app: FastAPI): @@ -44,21 +56,73 @@ async def lifespan(app: FastAPI): # Create FastAPI application app = FastAPI( title=settings.app_name, - description="Domain availability monitoring service", + description=""" +# pounce API + +Domain availability monitoring and portfolio management service. + +## Features + +- **Domain Monitoring**: Track domains and get notified when they become available +- **TLD Pricing**: Real-time TLD price comparison across registrars +- **Portfolio Management**: Track your domain investments and valuations +- **Smart Pounce Auctions**: Find undervalued domains in auctions + +## Authentication + +Most endpoints require authentication via Bearer token. +Get a token via POST /api/v1/auth/login + +## Rate Limits + +- Default: 200 requests/minute per IP +- Auth endpoints: 10 requests/minute +- Contact form: 5 requests/hour + +## Support + +For API issues, contact support@pounce.ch + """, version="1.0.0", lifespan=lifespan, - redirect_slashes=False, # Prevent 307 redirects for trailing slashes + redirect_slashes=False, + docs_url="/docs", + redoc_url="/redoc", ) +# Add rate limiter to app state +app.state.limiter = limiter + +# Custom rate limit exceeded handler +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={ + "error": "rate_limit_exceeded", + "detail": "Too many requests. Please slow down.", + "retry_after": exc.detail, + }, + ) + +# Get allowed origins from environment +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "").split(",") +if not ALLOWED_ORIGINS or ALLOWED_ORIGINS == [""]: + ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://10.42.0.73:3000", + ] + +# Add production origins +SITE_URL = os.getenv("SITE_URL", "") +if SITE_URL and SITE_URL not in ALLOWED_ORIGINS: + ALLOWED_ORIGINS.append(SITE_URL) + # Configure CORS app.add_middleware( CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://127.0.0.1:3000", - "http://10.42.0.73:3000", - # Add production origins here - ], + allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -70,16 +134,35 @@ app.include_router(api_router, prefix="/api/v1") @app.get("/") async def root(): - """Root endpoint.""" + """Root endpoint - API info.""" return { "name": settings.app_name, "version": "1.0.0", "status": "running", + "docs": "/docs", + "health": "/health", } @app.get("/health") async def health_check(): - """Health check endpoint.""" - return {"status": "healthy"} + """Health check endpoint for monitoring.""" + return { + "status": "healthy", + "service": settings.app_name, + "version": "1.0.0", + } + +# Rate-limited endpoints - apply specific limits to sensitive routes +from fastapi import Depends + +@app.middleware("http") +async def add_rate_limit_headers(request: Request, call_next): + """Add rate limit info to response headers.""" + response = await call_next(request) + + # Add CORS headers for rate limit info + response.headers["X-RateLimit-Policy"] = "200/minute" + + return response diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 36d477f..81383ea 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.subscription import Subscription from app.models.tld_price import TLDPrice, TLDInfo from app.models.portfolio import PortfolioDomain, DomainValuation from app.models.auction import DomainAuction, AuctionScrapeLog +from app.models.newsletter import NewsletterSubscriber __all__ = [ "User", @@ -17,4 +18,5 @@ __all__ = [ "DomainValuation", "DomainAuction", "AuctionScrapeLog", + "NewsletterSubscriber", ] diff --git a/backend/app/models/newsletter.py b/backend/app/models/newsletter.py new file mode 100644 index 0000000..005a972 --- /dev/null +++ b/backend/app/models/newsletter.py @@ -0,0 +1,34 @@ +"""Newsletter subscriber model.""" +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class NewsletterSubscriber(Base): + """Newsletter subscriber model.""" + + __tablename__ = "newsletter_subscribers" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + + # Status + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Unsubscribe token for one-click unsubscribe + unsubscribe_token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + + # Timestamps + subscribed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + unsubscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Optional tracking + source: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # e.g., "homepage", "blog", "footer" + + def __repr__(self) -> str: + status = "active" if self.is_active else "inactive" + return f"" + diff --git a/backend/app/models/user.py b/backend/app/models/user.py index bdd39a7..5f9bfe3 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -26,11 +26,20 @@ class User(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + # Password Reset + password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + password_reset_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Email Verification + email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow ) + last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # Relationships domains: Mapped[List["Domain"]] = relationship( @@ -46,3 +55,11 @@ class User(Base): def __repr__(self) -> str: return f"" + # Property aliases for compatibility + @property + def password_hash(self) -> str: + return self.hashed_password + + @password_hash.setter + def password_hash(self, value: str): + self.hashed_password = value diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 2a37b05..3fe12c6 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -8,6 +8,8 @@ Email Types: - Price change notifications - Subscription confirmations - Password reset +- Email verification +- Contact form messages - Weekly digests Environment Variables Required: @@ -20,8 +22,7 @@ Environment Variables Required: """ import logging import os -import asyncio -from typing import Optional, List, Dict, Any +from typing import Optional, List from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from datetime import datetime @@ -42,166 +43,281 @@ SMTP_CONFIG = { "use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true", } +# Contact email - where contact form submissions are sent +CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "support@pounce.ch") -# Email Templates -TEMPLATES = { - "domain_available": """ + +# Base email wrapper template +BASE_TEMPLATE = """ + +
-

Domain Available!

-

Great news! A domain you're monitoring is now available for registration:

-
{{ domain }}
-

This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!

- Register Now → + {{ content }}
+""" + + +# Email Templates (content only, wrapped in BASE_TEMPLATE) +TEMPLATES = { + "domain_available": """ +

Domain Available!

+

Great news! A domain you're monitoring is now available for registration:

+
{{ domain }}
+

This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!

+Register Now → +

+ You're receiving this because you're monitoring this domain on pounce. +

""", "price_alert": """ - - - - - - -
- -

Price Alert: .{{ tld }}

-

- {% if change_percent < 0 %} - ↓ Price dropped {{ change_percent|abs }}% - {% else %} - ↑ Price increased {{ change_percent }}% - {% endif %} -

-

- Old price: ${{ old_price }}
- New price: ${{ new_price }}
- Cheapest registrar: {{ registrar }} -

- View Details → - -
- - +

Price Alert: .{{ tld }}

+

+ {% if change_percent < 0 %} + ↓ Price dropped {{ change_percent|abs }}% + {% else %} + ↑ Price increased {{ change_percent }}% + {% endif %} +

+
+

Old price: ${{ old_price }}

+

New price: ${{ new_price }}

+

Cheapest registrar: {{ registrar }}

+
+View Details → +

+ You're receiving this because you set a price alert for .{{ tld }} on pounce. +

""", "subscription_confirmed": """ - - - - - - -
- -

Welcome to {{ plan_name }}!

-

Your subscription is now active. Here's what you can do:

-
-
    - {% for feature in features %} -
  • {{ feature }}
  • - {% endfor %} -
-
- Go to Dashboard → - -
- - +

Welcome to {{ plan_name }}!

+

Your subscription is now active. Here's what you can do:

+
+
    + {% for feature in features %} +
  • {{ feature }}
  • + {% endfor %} +
+
+Go to Dashboard → +

+ Questions? Reply to this email or contact support@pounce.ch +

""", "weekly_digest": """ - - - - - - -
- -

Your Weekly Digest

-

Here's what happened with your monitored domains this week:

- -
- Domains Monitored - {{ total_domains }} -
-
- Status Changes - {{ status_changes }} -
-
- Price Alerts - {{ price_alerts }} -
- - {% if available_domains %} -

Domains Now Available

- {% for domain in available_domains %} -

{{ domain }}

- {% endfor %} - {% endif %} - - View Dashboard → - -
- - +

Your Weekly Digest

+

Here's what happened with your monitored domains this week:

+ +
+ Domains Monitored + {{ total_domains }} +
+
+ Status Changes + {{ status_changes }} +
+
+ Price Alerts + {{ price_alerts }} +
+ +{% if available_domains %} +

Domains Now Available

+
+{% for domain in available_domains %} +

{{ domain }}

+{% endfor %} +
+{% endif %} + +View Dashboard → +""", + + "password_reset": """ +

Reset Your Password

+

Hi {{ user_name }},

+

We received a request to reset your password. Click the button below to set a new password:

+Reset Password → +

Or copy and paste this link into your browser:

+{{ reset_url }} +
+

⚠️ This link expires in 1 hour.

+
+

+ If you didn't request a password reset, you can safely ignore this email. + Your password won't be changed. +

+""", + + "email_verification": """ +

Verify Your Email

+

Hi {{ user_name }},

+

Welcome to pounce! Please verify your email address to activate your account:

+Verify Email → +

Or copy and paste this link into your browser:

+{{ verification_url }} +
+

This link expires in 24 hours.

+
+

+ If you didn't create an account on pounce, you can safely ignore this email. +

+""", + + "contact_form": """ +

New Contact Form Submission

+
+

From: {{ name }} <{{ email }}>

+

Subject: {{ subject }}

+

Date: {{ timestamp }}

+
+

Message

+
+

{{ message }}

+
+

+ Reply to {{ name }} → +

+""", + + "contact_confirmation": """ +

We've Received Your Message

+

Hi {{ name }},

+

Thank you for contacting pounce! We've received your message and will get back to you as soon as possible.

+
+

Subject: {{ subject }}

+

Your message:

+

{{ message }}

+
+

We typically respond within 24-48 hours during business days.

+Back to pounce → +""", + + "newsletter_welcome": """ +

Welcome to pounce Insights!

+

Hi there,

+

You're now subscribed to our newsletter. Here's what you can expect:

+
+
    +
  • TLD market trends and analysis
  • +
  • Domain investing tips and strategies
  • +
  • New feature announcements
  • +
  • Exclusive deals and discounts
  • +
+
+

We typically send 1-2 emails per month. No spam, ever.

+Explore pounce → +

+ You can unsubscribe at any time by clicking the link at the bottom of any email. +

""", } @@ -222,6 +338,18 @@ class EmailService: SMTP_CONFIG["password"] ) + @staticmethod + def _render_email(template_name: str, **kwargs) -> str: + """Render email with base template wrapper.""" + content_template = Template(TEMPLATES.get(template_name, "")) + content = content_template.render(**kwargs) + + base_template = Template(BASE_TEMPLATE) + return base_template.render( + content=content, + year=datetime.utcnow().year, + ) + @staticmethod async def send_email( to_email: str, @@ -236,7 +364,7 @@ class EmailService: to_email: Recipient email address subject: Email subject html_content: HTML body - text_content: Plain text body (optional, for email clients that don't support HTML) + text_content: Plain text body (optional) Returns: True if sent successfully, False otherwise @@ -275,6 +403,8 @@ class EmailService: logger.error(f"Failed to send email to {to_email}: {e}") return False + # ============== Domain Alerts ============== + @staticmethod async def send_domain_available( to_email: str, @@ -283,13 +413,12 @@ class EmailService: ) -> bool: """Send domain available notification.""" if not register_url: - register_url = f"https://pounce.ch/dashboard" + register_url = "https://pounce.ch/dashboard" - template = Template(TEMPLATES["domain_available"]) - html = template.render( + html = EmailService._render_email( + "domain_available", domain=domain, register_url=register_url, - year=datetime.utcnow().year, ) return await EmailService.send_email( @@ -310,15 +439,14 @@ class EmailService: """Send TLD price change alert.""" change_percent = round(((new_price - old_price) / old_price) * 100, 1) - template = Template(TEMPLATES["price_alert"]) - html = template.render( + html = EmailService._render_email( + "price_alert", tld=tld, old_price=f"{old_price:.2f}", new_price=f"{new_price:.2f}", change_percent=change_percent, registrar=registrar, tld_url=f"https://pounce.ch/tld-pricing/{tld}", - year=datetime.utcnow().year, ) direction = "dropped" if change_percent < 0 else "increased" @@ -329,6 +457,8 @@ class EmailService: text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.", ) + # ============== Subscription ============== + @staticmethod async def send_subscription_confirmed( to_email: str, @@ -336,12 +466,11 @@ class EmailService: features: List[str], ) -> bool: """Send subscription confirmation email.""" - template = Template(TEMPLATES["subscription_confirmed"]) - html = template.render( + html = EmailService._render_email( + "subscription_confirmed", plan_name=plan_name, features=features, dashboard_url="https://pounce.ch/dashboard", - year=datetime.utcnow().year, ) return await EmailService.send_email( @@ -351,6 +480,8 @@ class EmailService: text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.", ) + # ============== Digest ============== + @staticmethod async def send_weekly_digest( to_email: str, @@ -360,22 +491,128 @@ class EmailService: available_domains: List[str], ) -> bool: """Send weekly summary digest.""" - template = Template(TEMPLATES["weekly_digest"]) - html = template.render( + html = EmailService._render_email( + "weekly_digest", total_domains=total_domains, status_changes=status_changes, price_alerts=price_alerts, available_domains=available_domains, dashboard_url="https://pounce.ch/dashboard", - year=datetime.utcnow().year, ) return await EmailService.send_email( to_email=to_email, - subject=f"📬 Your pounce Weekly Digest", + subject="📬 Your pounce Weekly Digest", html_content=html, text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.", ) + + # ============== Authentication ============== + + @staticmethod + async def send_password_reset( + to_email: str, + user_name: str, + reset_url: str, + ) -> bool: + """Send password reset email.""" + html = EmailService._render_email( + "password_reset", + user_name=user_name, + reset_url=reset_url, + ) + + return await EmailService.send_email( + to_email=to_email, + subject="🔒 Reset Your pounce Password", + html_content=html, + text_content=f"Hi {user_name}, reset your password by visiting: {reset_url}. This link expires in 1 hour.", + ) + + @staticmethod + async def send_email_verification( + to_email: str, + user_name: str, + verification_url: str, + ) -> bool: + """Send email verification email.""" + html = EmailService._render_email( + "email_verification", + user_name=user_name, + verification_url=verification_url, + ) + + return await EmailService.send_email( + to_email=to_email, + subject="✉️ Verify Your pounce Email", + html_content=html, + text_content=f"Hi {user_name}, verify your email by visiting: {verification_url}. This link expires in 24 hours.", + ) + + # ============== Contact Form ============== + + @staticmethod + async def send_contact_form( + name: str, + email: str, + subject: str, + message: str, + ) -> bool: + """ + Send contact form submission to support. + Also sends confirmation to the user. + """ + timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + # Send to support + support_html = EmailService._render_email( + "contact_form", + name=name, + email=email, + subject=subject, + message=message, + timestamp=timestamp, + ) + + support_sent = await EmailService.send_email( + to_email=CONTACT_EMAIL, + subject=f"[Contact] {subject} - from {name}", + html_content=support_html, + text_content=f"From: {name} <{email}>\nSubject: {subject}\n\n{message}", + ) + + # Send confirmation to user + confirm_html = EmailService._render_email( + "contact_confirmation", + name=name, + subject=subject, + message=message, + ) + + confirm_sent = await EmailService.send_email( + to_email=email, + subject="We've received your message - pounce", + html_content=confirm_html, + text_content=f"Hi {name}, we've received your message and will get back to you soon.", + ) + + return support_sent # Return whether support email was sent + + # ============== Newsletter ============== + + @staticmethod + async def send_newsletter_welcome( + to_email: str, + ) -> bool: + """Send newsletter subscription welcome email.""" + html = EmailService._render_email("newsletter_welcome") + + return await EmailService.send_email( + to_email=to_email, + subject="🎉 Welcome to pounce Insights!", + html_content=html, + text_content="Welcome to pounce Insights! You'll receive TLD market trends, domain investing tips, and feature announcements.", + ) # Global instance diff --git a/backend/env.example b/backend/env.example index e28981c..5d54e27 100644 --- a/backend/env.example +++ b/backend/env.example @@ -23,7 +23,10 @@ SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters ACCESS_TOKEN_EXPIRE_MINUTES=10080 # CORS Origins (comma-separated) -CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# Email Verification (set to "true" to require email verification before login) +REQUIRE_EMAIL_VERIFICATION=false # ================================= # Stripe Payments @@ -33,6 +36,7 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret # Price IDs from Stripe Dashboard (Products > Prices) +# Create products "Trader" and "Tycoon" in Stripe, then get their Price IDs STRIPE_PRICE_TRADER=price_xxxxxxxxxxxxxx STRIPE_PRICE_TYCOON=price_xxxxxxxxxxxxxx @@ -65,6 +69,9 @@ SMTP_FROM_EMAIL=noreply@pounce.ch SMTP_FROM_NAME=pounce SMTP_USE_TLS=true +# Email for contact form submissions +CONTACT_EMAIL=support@pounce.ch + # ================================= # Scheduler Settings # ================================= @@ -86,5 +93,14 @@ ENVIRONMENT=development # Debug mode (disable in production!) DEBUG=true -# Site URL (for email links) +# Site URL (for email links, password reset, etc.) SITE_URL=http://localhost:3000 + +# ================================= +# Rate Limiting +# ================================= +# Default rate limit (requests per minute per IP) +# Rate limits are enforced in API endpoints +# Contact form: 5/hour +# Auth (login/register): 10/minute +# General API: 200/minute diff --git a/backend/requirements.txt b/backend/requirements.txt index 1895e89..8fb4014 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,35 +1,46 @@ +# FastAPI & Server +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +python-multipart>=0.0.12 + +# Database +sqlalchemy>=2.0.35 +aiosqlite>=0.20.0 +asyncpg>=0.30.0 +alembic>=1.14.0 # Authentication -# Database -# Domain Checking -# Email (optional, for notifications) -# FastAPI & Server -# Production Database (optional) -# Scheduling -# Utilities -# Web Scraping -aiosmtplib>=3.0.2 -aiosqlite>=0.20.0 -alembic>=1.14.0 -apscheduler>=3.10.4 -asyncpg>=0.30.0 +passlib[bcrypt]>=1.7.4 bcrypt>=4.0.0,<4.1 -beautifulsoup4>=4.12.0 +python-jose[cryptography]>=3.3.0 + +# Validation & Settings +pydantic[email]>=2.10.0 +pydantic-settings>=2.6.0 +python-dotenv>=1.0.1 + +# Domain Checking +python-whois>=0.9.4 +whodap>=0.1.12 dnspython>=2.7.0 -fastapi>=0.115.0 + +# Web Scraping httpx>=0.28.0 -jinja2>=3.1.2 +beautifulsoup4>=4.12.0 lxml>=5.0.0 +# Scheduling +apscheduler>=3.10.4 + +# Email (SMTP) +aiosmtplib>=3.0.2 +jinja2>=3.1.2 + # Payments stripe>=7.0.0 -passlib[bcrypt]>=1.7.4 -pydantic-settings>=2.6.0 -pydantic[email]>=2.10.0 -python-dotenv>=1.0.1 -python-jose[cryptography]>=3.3.0 -python-multipart>=0.0.12 -python-whois>=0.9.4 -sqlalchemy>=2.0.35 -uvicorn[standard]>=0.32.0 -whodap>=0.1.12 + +# Rate Limiting +slowapi>=0.1.9 + +# Production Database (optional) +# asyncpg>=0.30.0 # Already included above diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx index bd0e1ce..d7ef9b0 100644 --- a/frontend/src/app/contact/page.tsx +++ b/frontend/src/app/contact/page.tsx @@ -3,7 +3,8 @@ import { useState } from 'react' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' -import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building } from 'lucide-react' +import { api } from '@/lib/api' +import { Mail, MessageSquare, Clock, Send, Loader2, CheckCircle, MapPin, Building, AlertCircle } from 'lucide-react' import Link from 'next/link' const contactMethods = [ @@ -46,7 +47,8 @@ const faqs = [ ] export default function ContactPage() { - const [formState, setFormState] = useState<'idle' | 'loading' | 'success'>('idle') + const [formState, setFormState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [error, setError] = useState(null) const [formData, setFormData] = useState({ name: '', email: '', @@ -57,16 +59,26 @@ export default function ContactPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setFormState('loading') + setError(null) - // Simulate form submission - await new Promise(resolve => setTimeout(resolve, 1500)) - setFormState('success') - - // Reset after showing success - setTimeout(() => { - setFormState('idle') - setFormData({ name: '', email: '', subject: '', message: '' }) - }, 3000) + try { + await api.submitContact( + formData.name, + formData.email, + formData.subject, + formData.message + ) + setFormState('success') + + // Reset after showing success + setTimeout(() => { + setFormState('idle') + setFormData({ name: '', email: '', subject: '', message: '' }) + }, 5000) + } catch (err: any) { + setFormState('error') + setError(err.message || 'Failed to send message. Please try again.') + } } return ( @@ -132,11 +144,17 @@ export default function ContactPage() {

Message Sent!

- We'll get back to you within 24 hours. + We've sent you a confirmation email. We'll get back to you within 24 hours.

) : (
+ {formState === 'error' && error && ( +
+ +

{error}

+
+ )}
diff --git a/frontend/src/app/forgot-password/page.tsx b/frontend/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..3b85cfc --- /dev/null +++ b/frontend/src/app/forgot-password/page.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useState } from 'react' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { api } from '@/lib/api' +import { Mail, ArrowLeft, CheckCircle, AlertCircle } from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + await api.forgotPassword(email) + setSuccess(true) + } catch (err: any) { + // Always show success message (security: don't reveal if email exists) + setSuccess(true) + } finally { + setLoading(false) + } + } + + return ( + <> +
+
+
+ + + Back to login + + + {success ? ( +
+
+ +
+

+ Check your email +

+

+ If an account with {email} exists, + we've sent a password reset link. The link expires in 1 hour. +

+

+ Didn't receive the email? Check your spam folder or{' '} + +

+
+ ) : ( +
+
+

+ Forgot your password? +

+

+ Enter your email and we'll send you a reset link. +

+
+ + + {error && ( +
+ +

{error}

+
+ )} + +
+ +
+ + setEmail(e.target.value)} + required + placeholder="you@example.com" + className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" + /> +
+
+ + + +
+ )} +
+
+
+ + ) +} + diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index ece058b..bba6d84 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -108,6 +108,15 @@ export default function LoginPage() {
+
+ + Forgot password? + +
+ + + + {/* Password strength indicator */} + {password && ( +
+
+
+
+

{strength.label}

+
+ )} +
+ +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + placeholder="Confirm your password" + className="w-full pl-12 pr-4 py-3 bg-background-tertiary border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" + /> +
+ {confirmPassword && password !== confirmPassword && ( +

Passwords do not match

+ )} +
+ + + + + )} + + +