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
|
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
|
- **Domain Availability Monitoring** — Track any domain and get notified when it becomes available
|
||||||
- **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API
|
- **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API
|
||||||
- **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs
|
- **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
|
- **Expiration Tracking** — Monitor domain expiration dates and plan ahead
|
||||||
- **Real-time Checks** — RDAP, WHOIS, and DNS-based availability verification
|
- **Real-time Checks** — RDAP, WHOIS, and DNS-based availability verification
|
||||||
- **Swiss Domain Support** — Special RDAP integration for .ch/.li domains via nic.ch
|
- **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)
|
- **Historical Data** — Track price trends and availability history over time (12 months)
|
||||||
|
- **Email Notifications** — Domain available alerts, price changes, weekly digests
|
||||||
|
|
||||||
### User Experience
|
### User Experience
|
||||||
- **Modern UI** — Clean, minimalist dark-mode design with smooth animations
|
- **Modern UI** — Clean, minimalist dark-mode design with smooth animations
|
||||||
@ -72,6 +74,8 @@ pounce/
|
|||||||
│ │ ├── services/ # Business logic
|
│ │ ├── services/ # Business logic
|
||||||
│ │ │ ├── auth.py # Auth service (JWT, hashing)
|
│ │ │ ├── auth.py # Auth service (JWT, hashing)
|
||||||
│ │ │ ├── domain_checker.py # RDAP/WHOIS/DNS checks
|
│ │ │ ├── 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
|
│ │ │ └── tld_scraper/ # TLD price scraping
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
│ │ │ ├── base.py # Base scraper class
|
│ │ │ ├── base.py # Base scraper class
|
||||||
@ -82,6 +86,11 @@ pounce/
|
|||||||
│ │ ├── database.py # Database connection
|
│ │ ├── database.py # Database connection
|
||||||
│ │ ├── main.py # FastAPI app entry
|
│ │ ├── main.py # FastAPI app entry
|
||||||
│ │ └── scheduler.py # Background jobs
|
│ │ └── 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
|
│ ├── requirements.txt
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── env.example
|
│ └── 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 |
|
| **Domain Check** | Configurable (default: daily) | Checks all watched domains |
|
||||||
| **TLD Price Scrape** | Daily at 03:00 UTC | Scrapes current TLD prices |
|
| **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
|
### 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
|
## Design System
|
||||||
|
|
||||||
### Colors
|
### 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 asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -10,7 +10,10 @@ from sqlalchemy import select
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.domain import Domain, DomainCheck
|
from app.models.domain import Domain, DomainCheck
|
||||||
|
from app.models.user import User
|
||||||
from app.services.domain_checker import domain_checker
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@ -103,10 +106,10 @@ async def check_all_domains():
|
|||||||
f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s"
|
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:
|
if newly_available:
|
||||||
logger.info(f"Domains that became available: {[d.name for d in 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():
|
def setup_scheduler():
|
||||||
@ -129,10 +132,20 @@ def setup_scheduler():
|
|||||||
replace_existing=True,
|
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(
|
logger.info(
|
||||||
f"Scheduler configured:"
|
f"Scheduler configured:"
|
||||||
f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}"
|
f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}"
|
||||||
f"\n - TLD price scrape at 03:00 UTC"
|
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)."""
|
"""Run TLD price scrape manually (for testing or on-demand)."""
|
||||||
await scrape_tld_prices()
|
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