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
This commit is contained in:
174
DEPLOYMENT.md
174
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
|
||||
|
||||
|
||||
63
README.md
63
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
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
303
backend/app/services/email_service.py
Normal file
303
backend/app/services/email_service.py
Normal file
@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
|
||||
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
|
||||
.title {{ font-size: 28px; margin-bottom: 16px; }}
|
||||
.domain {{ color: #00d4aa; font-size: 32px; font-weight: bold; background: #0a0a0a; padding: 16px 24px; border-radius: 8px; display: inline-block; margin: 16px 0; }}
|
||||
.cta {{ display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 24px; }}
|
||||
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">• pounce</div>
|
||||
<h1 class="title">Great news{f', {user_name}' if user_name else ''}!</h1>
|
||||
<p>A domain you're watching just became available:</p>
|
||||
<div class="domain">{domain}</div>
|
||||
<p>This is your chance to register it before someone else does!</p>
|
||||
<a href="https://porkbun.com/checkout/search?q={domain}" class="cta">Register Now →</a>
|
||||
<div class="footer">
|
||||
<p>You're receiving this because you added {domain} to your watchlist on Pounce.</p>
|
||||
<p>— The Pounce Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
|
||||
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
|
||||
.title {{ font-size: 24px; margin-bottom: 16px; }}
|
||||
.tld {{ color: {color}; font-size: 48px; font-weight: bold; }}
|
||||
.price-box {{ background: #0a0a0a; padding: 24px; border-radius: 8px; margin: 24px 0; }}
|
||||
.price {{ font-size: 24px; }}
|
||||
.old {{ color: #888; text-decoration: line-through; }}
|
||||
.new {{ color: {color}; font-weight: bold; }}
|
||||
.change {{ color: {color}; font-size: 20px; margin-top: 8px; }}
|
||||
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">• pounce</div>
|
||||
<h1 class="title">TLD Price Change Alert</h1>
|
||||
<div class="tld">.{tld}</div>
|
||||
<div class="price-box">
|
||||
<div class="price">
|
||||
<span class="old">${old_price:.2f}</span> →
|
||||
<span class="new">${new_price:.2f}</span>
|
||||
</div>
|
||||
<div class="change">{change_percent:+.1f}% ({registrar})</div>
|
||||
</div>
|
||||
<p>{"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."}</p>
|
||||
<div class="footer">
|
||||
<p>You're subscribed to TLD price alerts on Pounce.</p>
|
||||
<p>— The Pounce Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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'<tr><td style="padding: 8px 0;">{d["domain"]}</td><td style="color: {status_color};">{status_text}</td></tr>'
|
||||
|
||||
# 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'<tr><td style="padding: 8px 0;">.{p["tld"]}</td><td style="color: {color};">{change:+.1f}%</td><td>${p.get("new_price", 0):.2f}</td></tr>'
|
||||
|
||||
html_body = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
|
||||
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
|
||||
.section {{ margin: 24px 0; }}
|
||||
.section-title {{ font-size: 18px; color: #888; margin-bottom: 12px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th {{ text-align: left; color: #888; font-weight: normal; padding: 8px 0; border-bottom: 1px solid #333; }}
|
||||
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">• pounce</div>
|
||||
<h1>Weekly Digest</h1>
|
||||
<p>Hi {user_name}, here's your weekly summary:</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Your Watched Domains</div>
|
||||
<table>
|
||||
<tr><th>Domain</th><th>Status</th></tr>
|
||||
{domains_html if domains_html else '<tr><td colspan="2" style="color: #888;">No domains being watched</td></tr>'}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Notable Price Changes</div>
|
||||
<table>
|
||||
<tr><th>TLD</th><th>Change</th><th>Price</th></tr>
|
||||
{prices_html if prices_html else '<tr><td colspan="3" style="color: #888;">No significant changes this week</td></tr>'}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>— The Pounce Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return await self.send_email(to_email, subject, html_body)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_service = EmailService()
|
||||
|
||||
254
backend/app/services/price_tracker.py
Normal file
254
backend/app/services/price_tracker.py
Normal file
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user