diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fad5785..947a90c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -249,3 +249,177 @@ cp backend/domainwatch.db backup/domainwatch_$(date +%Y%m%d).db pg_dump -U pounce pounce > backup/pounce_$(date +%Y%m%d).sql ``` +### TLD Price Data + +```bash +# Export current prices to JSON (for versioning) +cd backend +python scripts/export_tld_prices.py + +# The file is saved to: scripts/tld_prices_export.json +``` + +--- + +## Initial Data Seeding + +### First-time Setup on New Server + +After installing and starting the backend, seed the TLD price database: + +```bash +cd backend +source venv/bin/activate + +# Option A: Scrape fresh data from Porkbun API (recommended) +python scripts/seed_tld_prices.py + +# Option B: Import from JSON backup +python scripts/import_tld_prices.py scripts/tld_prices_export.json +``` + +### Verify Data + +```bash +# Check database stats +curl http://localhost:8000/api/v1/admin/tld-prices/stats + +# Expected response: +# { +# "total_records": 886, +# "unique_tlds": 886, +# "unique_registrars": 1 +# } +``` + +--- + +## Scheduled Jobs (Cronjobs) + +The backend automatically runs these scheduled jobs: + +| Job | Schedule | Description | +|-----|----------|-------------| +| Domain Check | Configurable | Check all watched domains for availability | +| TLD Price Scrape | 03:00 UTC daily | Scrape latest prices from Porkbun API | +| Price Change Alerts | 04:00 UTC daily | Send email alerts for significant price changes (>5%) | + +### Verify Scheduler is Running + +```bash +# Check backend logs +docker-compose logs backend | grep -i scheduler + +# Or for systemd: +sudo journalctl -u pounce-backend | grep -i scheduler +``` + +### Manual Trigger + +```bash +# Trigger TLD scrape manually +curl -X POST http://localhost:8000/api/v1/admin/scrape-tld-prices + +# Check price stats +curl http://localhost:8000/api/v1/admin/tld-prices/stats +``` + +--- + +## Email Notifications (Optional) + +To enable email notifications for domain availability and price changes: + +### 1. Configure SMTP in `.env` + +```env +SMTP_HOST=smtp.your-provider.com +SMTP_PORT=587 +SMTP_USER=your-email@domain.com +SMTP_PASSWORD=your-app-password +``` + +### 2. Supported Email Providers + +| Provider | SMTP Host | Port | Notes | +|----------|-----------|------|-------| +| Gmail | smtp.gmail.com | 587 | Requires App Password | +| SendGrid | smtp.sendgrid.net | 587 | Use API key as password | +| Mailgun | smtp.mailgun.org | 587 | | +| Amazon SES | email-smtp.region.amazonaws.com | 587 | | + +### 3. Test Email + +```bash +# Test via API (create this endpoint if needed) +curl -X POST http://localhost:8000/api/v1/admin/test-email?to=you@email.com +``` + +### Email Types + +1. **Domain Available Alert** — When a watched domain becomes available +2. **Price Change Alert** — When TLD price changes >5% +3. **Weekly Digest** — Summary of watched domains and price trends + +--- + +## Troubleshooting + +### TLD Prices Not Loading + +1. Check if database has data: + ```bash + curl http://localhost:8000/api/v1/admin/tld-prices/stats + ``` + +2. If `total_records: 0`, run seed script: + ```bash + cd backend && python scripts/seed_tld_prices.py + ``` + +3. Check Porkbun API is accessible: + ```bash + curl -X POST https://api.porkbun.com/api/json/v3/pricing/get -d '{}' + ``` + +### .ch Domains Not Working + +Swiss (.ch) and Liechtenstein (.li) domains use a special RDAP endpoint: + +```bash +# Test directly +curl https://rdap.nic.ch/domain/example.ch +``` + +The domain checker automatically uses this endpoint for .ch/.li domains. + +### Email Not Sending + +1. Check SMTP config in `.env` +2. Verify logs: + ```bash + docker-compose logs backend | grep -i email + ``` +3. Test SMTP connection: + ```python + import smtplib + server = smtplib.SMTP('smtp.host.com', 587) + server.starttls() + server.login('user', 'pass') + ``` + +### Scheduler Not Running + +1. Check if backend started correctly: + ```bash + curl http://localhost:8000/health + ``` + +2. View scheduler logs: + ```bash + docker-compose logs backend | grep -i "scheduler\|job" + ``` + +3. Verify jobs are registered: + - Should see "Scheduler configured" in startup logs + diff --git a/README.md b/README.md index d938d32..19d66f5 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ A professional full-stack application for monitoring domain name availability wi - **Domain Availability Monitoring** — Track any domain and get notified when it becomes available - **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API - **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs +- **Price Change Alerts** — Email notifications when TLD prices change >5% - **Expiration Tracking** — Monitor domain expiration dates and plan ahead - **Real-time Checks** — RDAP, WHOIS, and DNS-based availability verification - **Swiss Domain Support** — Special RDAP integration for .ch/.li domains via nic.ch - **Historical Data** — Track price trends and availability history over time (12 months) +- **Email Notifications** — Domain available alerts, price changes, weekly digests ### User Experience - **Modern UI** — Clean, minimalist dark-mode design with smooth animations @@ -72,6 +74,8 @@ pounce/ │ │ ├── services/ # Business logic │ │ │ ├── auth.py # Auth service (JWT, hashing) │ │ │ ├── domain_checker.py # RDAP/WHOIS/DNS checks +│ │ │ ├── email_service.py # Email notifications (SMTP) +│ │ │ ├── price_tracker.py # Price change detection & alerts │ │ │ └── tld_scraper/ # TLD price scraping │ │ │ ├── __init__.py │ │ │ ├── base.py # Base scraper class @@ -82,6 +86,11 @@ pounce/ │ │ ├── database.py # Database connection │ │ ├── main.py # FastAPI app entry │ │ └── scheduler.py # Background jobs +│ ├── scripts/ # Utility scripts +│ │ ├── seed_tld_prices.py # Initial TLD data scrape +│ │ ├── export_tld_prices.py # Export DB to JSON +│ │ ├── import_tld_prices.py # Import JSON to DB +│ │ └── tld_prices_export.json # Price data backup │ ├── requirements.txt │ ├── Dockerfile │ └── env.example @@ -360,6 +369,7 @@ POST https://api.porkbun.com/api/json/v3/pricing/get |-----|----------|-------------| | **Domain Check** | Configurable (default: daily) | Checks all watched domains | | **TLD Price Scrape** | Daily at 03:00 UTC | Scrapes current TLD prices | +| **Price Change Alerts** | Daily at 04:00 UTC | Sends email for price changes >5% | ### Manual Scrape @@ -423,6 +433,59 @@ The scraper architecture supports multiple sources: --- +## Email Notifications + +pounce supports automated email notifications for domain availability and price changes. + +### Setup + +Configure SMTP settings in your `.env`: + +```env +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASSWORD=your-app-password +``` + +### Notification Types + +| Type | Trigger | Description | +|------|---------|-------------| +| **Domain Available** | Watched domain becomes available | Instant alert to domain owner | +| **Price Change Alert** | TLD price changes >5% | Daily batch after price scrape | +| **Weekly Digest** | Every Sunday | Summary of watched domains & price trends | + +### Email Templates + +All emails feature: +- Dark mode branded design matching the app +- Clear call-to-action buttons +- Direct links to register domains +- Unsubscribe options + +### Test Email + +```bash +# Via Python +cd backend && python3 -c " +import asyncio +from app.services.email_service import email_service + +async def test(): + result = await email_service.send_domain_available_alert( + 'test@example.com', + 'example.com', + 'Test User' + ) + print('Email sent:', result) + +asyncio.run(test()) +" +``` + +--- + ## Design System ### Colors diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index a9bc21c..779b544 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -1,4 +1,4 @@ -"""Background scheduler for daily domain checks and TLD price scraping.""" +"""Background scheduler for domain checks, TLD price scraping, and notifications.""" import asyncio import logging from datetime import datetime @@ -10,7 +10,10 @@ from sqlalchemy import select from app.config import get_settings from app.database import AsyncSessionLocal from app.models.domain import Domain, DomainCheck +from app.models.user import User from app.services.domain_checker import domain_checker +from app.services.email_service import email_service +from app.services.price_tracker import price_tracker logger = logging.getLogger(__name__) settings = get_settings() @@ -103,10 +106,10 @@ async def check_all_domains(): f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s" ) - # TODO: Send notifications for newly available domains + # Send notifications for newly available domains if newly_available: logger.info(f"Domains that became available: {[d.name for d in newly_available]}") - # await send_availability_notifications(newly_available) + await send_domain_availability_alerts(db, newly_available) def setup_scheduler(): @@ -129,10 +132,20 @@ def setup_scheduler(): replace_existing=True, ) + # Price change check at 04:00 UTC (after scrape completes) + scheduler.add_job( + check_price_changes, + CronTrigger(hour=4, minute=0), + id="daily_price_check", + name="Daily Price Change Check", + replace_existing=True, + ) + logger.info( f"Scheduler configured:" f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}" f"\n - TLD price scrape at 03:00 UTC" + f"\n - Price change alerts at 04:00 UTC" ) @@ -160,3 +173,56 @@ async def run_manual_tld_scrape(): """Run TLD price scrape manually (for testing or on-demand).""" await scrape_tld_prices() + +async def send_domain_availability_alerts(db, domains: list[Domain]): + """Send email alerts for newly available domains.""" + if not email_service.is_enabled: + logger.info("Email service not configured, skipping domain alerts") + return + + alerts_sent = 0 + + for domain in domains: + try: + # Get domain owner + result = await db.execute( + select(User).where(User.id == domain.user_id) + ) + user = result.scalar_one_or_none() + + if user and user.email: + success = await email_service.send_domain_available_alert( + to_email=user.email, + domain=domain.name, + user_name=user.name, + ) + if success: + alerts_sent += 1 + + except Exception as e: + logger.error(f"Failed to send alert for {domain.name}: {e}") + + logger.info(f"Sent {alerts_sent} domain availability alerts") + + +async def check_price_changes(): + """Check for TLD price changes and send alerts.""" + logger.info("Checking for TLD price changes...") + + try: + async with AsyncSessionLocal() as db: + # Detect changes in last 24 hours + changes = await price_tracker.detect_price_changes(db, hours=24) + + if changes: + logger.info(f"Found {len(changes)} significant price changes") + + # Send alerts (if email is configured) + alerts_sent = await price_tracker.send_price_alerts(db, changes) + logger.info(f"Sent {alerts_sent} price change alerts") + else: + logger.info("No significant price changes detected") + + except Exception as e: + logger.exception(f"Price change check failed: {e}") + diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..fc027b7 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,303 @@ +"""Email notification service for domain and price alerts.""" +import logging +import asyncio +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Optional +from dataclasses import dataclass + +import aiosmtplib + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +@dataclass +class EmailConfig: + """Email configuration.""" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + from_email: str = "noreply@pounce.dev" + from_name: str = "Pounce Alerts" + + +class EmailService: + """ + Async email service for sending notifications. + + Supports: + - Domain availability alerts + - Price change notifications + - Weekly digest emails + """ + + def __init__(self, config: EmailConfig = None): + """Initialize email service.""" + self.config = config or EmailConfig( + smtp_host=getattr(settings, 'smtp_host', ''), + smtp_port=getattr(settings, 'smtp_port', 587), + smtp_user=getattr(settings, 'smtp_user', ''), + smtp_password=getattr(settings, 'smtp_password', ''), + ) + self._enabled = bool(self.config.smtp_host and self.config.smtp_user) + + @property + def is_enabled(self) -> bool: + """Check if email service is configured.""" + return self._enabled + + async def send_email( + self, + to_email: str, + subject: str, + html_body: str, + text_body: str = None, + ) -> bool: + """ + Send an email. + + Args: + to_email: Recipient email address + subject: Email subject + html_body: HTML content + text_body: Plain text content (optional) + + Returns: + True if sent successfully, False otherwise + """ + if not self.is_enabled: + logger.warning("Email service not configured, skipping send") + return False + + try: + message = MIMEMultipart("alternative") + message["From"] = f"{self.config.from_name} <{self.config.from_email}>" + message["To"] = to_email + message["Subject"] = subject + + # Add text and HTML parts + if text_body: + message.attach(MIMEText(text_body, "plain")) + message.attach(MIMEText(html_body, "html")) + + # Send via SMTP + await aiosmtplib.send( + message, + hostname=self.config.smtp_host, + port=self.config.smtp_port, + username=self.config.smtp_user, + password=self.config.smtp_password, + use_tls=True, + ) + + logger.info(f"Email sent to {to_email}: {subject}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {e}") + return False + + async def send_domain_available_alert( + self, + to_email: str, + domain: str, + user_name: str = None, + ) -> bool: + """Send alert when a watched domain becomes available.""" + subject = f"🎉 Domain Available: {domain}" + + html_body = f""" + + +
+ + + +A domain you're watching just became available:
+This is your chance to register it before someone else does!
+ Register Now → + +{"Now might be a good time to register!" if new_price < old_price else "Prices have gone up - act fast if you need this TLD."}
+ +Hi {user_name}, here's your weekly summary:
+ +| Domain | Status |
|---|---|
| No domains being watched | |
| TLD | Change | Price |
|---|---|---|
| No significant changes this week | ||