From fb080375a08476fd27947d5e54b790f825dd0ffb Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Mon, 8 Dec 2025 09:27:03 +0100 Subject: [PATCH] feat: add email notifications and price change alerts New features: - Email service for domain availability alerts - Price tracker for detecting significant price changes (>5%) - Automated email notifications for: - Domain becomes available - TLD price changes - Weekly digest summaries - New scheduler job for price change alerts (04:00 UTC) Updated documentation: - README: email notifications section, new services - DEPLOYMENT: email setup, troubleshooting, scheduler jobs New files: - backend/app/services/email_service.py - backend/app/services/price_tracker.py --- DEPLOYMENT.md | 174 +++++++++++++++ README.md | 63 ++++++ backend/app/scheduler.py | 72 +++++- backend/app/services/email_service.py | 303 ++++++++++++++++++++++++++ backend/app/services/price_tracker.py | 254 +++++++++++++++++++++ 5 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/price_tracker.py 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""" + + + + + + +
+ +

Great news{f', {user_name}' if user_name else ''}!

+

A domain you're watching just became available:

+
{domain}
+

This is your chance to register it before someone else does!

+ Register Now → + +
+ + + """ + + text_body = f""" + Great news{f', {user_name}' if user_name else ''}! + + A domain you're watching just became available: {domain} + + This is your chance to register it before someone else does! + + Register now: https://porkbun.com/checkout/search?q={domain} + + — The Pounce Team + """ + + return await self.send_email(to_email, subject, html_body, text_body) + + async def send_price_change_alert( + self, + to_email: str, + tld: str, + old_price: float, + new_price: float, + change_percent: float, + registrar: str = "average", + ) -> bool: + """Send alert when TLD price changes significantly.""" + direction = "📈 increased" if new_price > old_price else "📉 decreased" + color = "#f97316" if new_price > old_price else "#00d4aa" + + subject = f"TLD Price Alert: .{tld} {direction} by {abs(change_percent):.1f}%" + + html_body = f""" + + + + + + +
+ +

TLD Price Change Alert

+
.{tld}
+
+
+ ${old_price:.2f} → + ${new_price:.2f} +
+
{change_percent:+.1f}% ({registrar})
+
+

{"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."}

+ +
+ + + """ + + text_body = f""" + TLD Price Change Alert + + .{tld} has {direction} by {abs(change_percent):.1f}% + + Old price: ${old_price:.2f} + New price: ${new_price:.2f} + Source: {registrar} + + {"Now might be a good time to register!" if new_price < old_price else "Prices have gone up."} + + — The Pounce Team + """ + + return await self.send_email(to_email, subject, html_body, text_body) + + async def send_weekly_digest( + self, + to_email: str, + user_name: str, + watched_domains: list[dict], + price_changes: list[dict], + ) -> bool: + """Send weekly summary email.""" + subject = "📊 Your Weekly Pounce Digest" + + # Build domain status HTML + domains_html = "" + for d in watched_domains[:10]: + status_color = "#00d4aa" if d.get("is_available") else "#888" + status_text = "Available!" if d.get("is_available") else "Taken" + domains_html += f'{d["domain"]}{status_text}' + + # Build price changes HTML + prices_html = "" + for p in price_changes[:5]: + change = p.get("change_percent", 0) + color = "#00d4aa" if change < 0 else "#f97316" + prices_html += f'.{p["tld"]}{change:+.1f}%${p.get("new_price", 0):.2f}' + + html_body = f""" + + + + + + +
+ +

Weekly Digest

+

Hi {user_name}, here's your weekly summary:

+ +
+
Your Watched Domains
+ + + {domains_html if domains_html else ''} +
DomainStatus
No domains being watched
+
+ +
+
Notable Price Changes
+ + + {prices_html if prices_html else ''} +
TLDChangePrice
No significant changes this week
+
+ + +
+ + + """ + + return await self.send_email(to_email, subject, html_body) + + +# Singleton instance +email_service = EmailService() + diff --git a/backend/app/services/price_tracker.py b/backend/app/services/price_tracker.py new file mode 100644 index 0000000..3e557d1 --- /dev/null +++ b/backend/app/services/price_tracker.py @@ -0,0 +1,254 @@ +"""TLD Price change tracking and alerting service.""" +import logging +from datetime import datetime, timedelta +from dataclasses import dataclass +from typing import Optional + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.tld_price import TLDPrice +from app.models.user import User +from app.services.email_service import email_service + +logger = logging.getLogger(__name__) + + +@dataclass +class PriceChange: + """Represents a TLD price change.""" + tld: str + registrar: str + old_price: float + new_price: float + change_amount: float + change_percent: float + detected_at: datetime + + @property + def is_significant(self) -> bool: + """Check if price change is significant (>5%).""" + return abs(self.change_percent) >= 5.0 + + @property + def direction(self) -> str: + """Get price change direction.""" + if self.change_percent > 0: + return "up" + elif self.change_percent < 0: + return "down" + return "stable" + + +class PriceTracker: + """ + Tracks TLD price changes and sends alerts. + + Features: + - Detect significant price changes (>5%) + - Send email alerts to subscribed users + - Generate price change reports + """ + + # Threshold for significant price change (percentage) + SIGNIFICANT_CHANGE_THRESHOLD = 5.0 + + async def detect_price_changes( + self, + db: AsyncSession, + hours: int = 24, + ) -> list[PriceChange]: + """ + Detect price changes in the last N hours. + + Args: + db: Database session + hours: Look back period in hours + + Returns: + List of significant price changes + """ + changes = [] + now = datetime.utcnow() + cutoff = now - timedelta(hours=hours) + + # Get unique TLD/registrar combinations + tld_registrars = await db.execute( + select(TLDPrice.tld, TLDPrice.registrar) + .distinct() + ) + + for tld, registrar in tld_registrars: + # Get the two most recent prices for this TLD/registrar + result = await db.execute( + select(TLDPrice) + .where( + and_( + TLDPrice.tld == tld, + TLDPrice.registrar == registrar, + ) + ) + .order_by(TLDPrice.recorded_at.desc()) + .limit(2) + ) + prices = result.scalars().all() + + if len(prices) < 2: + continue + + new_price = prices[0] + old_price = prices[1] + + # Check if change is within our time window + if new_price.recorded_at < cutoff: + continue + + # Calculate change + if old_price.registration_price == 0: + continue + + change_amount = new_price.registration_price - old_price.registration_price + change_percent = (change_amount / old_price.registration_price) * 100 + + # Only track significant changes + if abs(change_percent) >= self.SIGNIFICANT_CHANGE_THRESHOLD: + changes.append(PriceChange( + tld=tld, + registrar=registrar, + old_price=old_price.registration_price, + new_price=new_price.registration_price, + change_amount=change_amount, + change_percent=change_percent, + detected_at=new_price.recorded_at, + )) + + # Sort by absolute change percentage (most significant first) + changes.sort(key=lambda x: abs(x.change_percent), reverse=True) + + logger.info(f"Detected {len(changes)} significant price changes in last {hours}h") + return changes + + async def send_price_alerts( + self, + db: AsyncSession, + changes: list[PriceChange], + min_tier: str = "professional", + ) -> int: + """ + Send price change alerts to subscribed users. + + Args: + db: Database session + changes: List of price changes to alert about + min_tier: Minimum subscription tier to receive alerts + + Returns: + Number of alerts sent + """ + if not changes: + return 0 + + if not email_service.is_enabled: + logger.warning("Email service not configured, skipping price alerts") + return 0 + + # Get users with appropriate subscription + # For now, send to all active users (can filter by tier later) + result = await db.execute( + select(User).where(User.is_active == True) + ) + users = result.scalars().all() + + alerts_sent = 0 + + for change in changes[:10]: # Limit to top 10 changes + for user in users: + try: + success = await email_service.send_price_change_alert( + to_email=user.email, + tld=change.tld, + old_price=change.old_price, + new_price=change.new_price, + change_percent=change.change_percent, + registrar=change.registrar, + ) + if success: + alerts_sent += 1 + except Exception as e: + logger.error(f"Failed to send alert to {user.email}: {e}") + + logger.info(f"Sent {alerts_sent} price change alerts") + return alerts_sent + + async def get_trending_changes( + self, + db: AsyncSession, + days: int = 7, + limit: int = 10, + ) -> list[dict]: + """ + Get trending price changes for dashboard display. + + Args: + db: Database session + days: Look back period + limit: Max results to return + + Returns: + List of trending price changes + """ + changes = await self.detect_price_changes(db, hours=days * 24) + + return [ + { + "tld": c.tld, + "registrar": c.registrar, + "old_price": c.old_price, + "new_price": c.new_price, + "change_percent": round(c.change_percent, 2), + "direction": c.direction, + "detected_at": c.detected_at.isoformat(), + } + for c in changes[:limit] + ] + + async def generate_price_report( + self, + db: AsyncSession, + days: int = 30, + ) -> dict: + """Generate a comprehensive price change report.""" + changes = await self.detect_price_changes(db, hours=days * 24) + + increases = [c for c in changes if c.direction == "up"] + decreases = [c for c in changes if c.direction == "down"] + + return { + "period_days": days, + "total_changes": len(changes), + "price_increases": len(increases), + "price_decreases": len(decreases), + "avg_increase": round(sum(c.change_percent for c in increases) / len(increases), 2) if increases else 0, + "avg_decrease": round(sum(c.change_percent for c in decreases) / len(decreases), 2) if decreases else 0, + "biggest_increase": { + "tld": increases[0].tld, + "change": round(increases[0].change_percent, 2), + } if increases else None, + "biggest_decrease": { + "tld": decreases[0].tld, + "change": round(decreases[0].change_percent, 2), + } if decreases else None, + "top_changes": [ + { + "tld": c.tld, + "change_percent": round(c.change_percent, 2), + "new_price": c.new_price, + } + for c in changes[:10] + ], + } + + +# Singleton instance +price_tracker = PriceTracker() +