MOCK DATA REMOVED: - Removed ALL hardcoded auction data from auctions.py - Now uses real-time scraping from ExpiredDomains.net - Database stores scraped auctions (domain_auctions table) - Scraping runs hourly via scheduler (:30 each hour) AUCTION SCRAPER SERVICE: - Web scraping from ExpiredDomains.net (aggregator) - Rate limiting per platform (10 req/min) - Database caching to minimize requests - Cleanup of ended auctions (auto-deactivate) - Scrape logging for monitoring STRIPE INTEGRATION: - Full payment flow: Checkout → Webhook → Subscription update - Customer Portal for managing subscriptions - Price IDs configurable via env vars - Handles: checkout.completed, subscription.updated/deleted, payment.failed EMAIL SERVICE (SMTP): - Beautiful HTML email templates with pounce branding - Domain available alerts - Price change notifications - Subscription confirmations - Weekly digest emails - Configurable via SMTP_* env vars NEW SUBSCRIPTION TIERS: - Scout (Free): 5 domains, daily checks - Trader (€19/mo): 50 domains, hourly, portfolio, valuation - Tycoon (€49/mo): 500+ domains, realtime, API, bulk tools DATABASE CHANGES: - domain_auctions table for scraped data - auction_scrape_logs for monitoring - stripe_customer_id on users - stripe_subscription_id on subscriptions - portfolio_domain relationships fixed ENV VARS ADDED: - STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET - STRIPE_PRICE_TRADER, STRIPE_PRICE_TYCOON - SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD - SMTP_FROM_EMAIL, SMTP_FROM_NAME
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""
|
|
Email Service for pounce.ch
|
|
|
|
Sends transactional emails using SMTP.
|
|
|
|
Email Types:
|
|
- Domain availability alerts
|
|
- Price change notifications
|
|
- Subscription confirmations
|
|
- Password reset
|
|
- Weekly digests
|
|
|
|
Environment Variables Required:
|
|
- SMTP_HOST: SMTP server hostname
|
|
- SMTP_PORT: SMTP port (usually 587 for TLS, 465 for SSL)
|
|
- SMTP_USER: SMTP username
|
|
- SMTP_PASSWORD: SMTP password
|
|
- SMTP_FROM_EMAIL: Sender email address
|
|
- SMTP_FROM_NAME: Sender name (default: pounce)
|
|
"""
|
|
import logging
|
|
import os
|
|
import asyncio
|
|
from typing import Optional, List, Dict, Any
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from datetime import datetime
|
|
|
|
import aiosmtplib
|
|
from jinja2 import Template
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# SMTP Configuration from environment
|
|
SMTP_CONFIG = {
|
|
"host": os.getenv("SMTP_HOST"),
|
|
"port": int(os.getenv("SMTP_PORT", "587")),
|
|
"username": os.getenv("SMTP_USER"),
|
|
"password": os.getenv("SMTP_PASSWORD"),
|
|
"from_email": os.getenv("SMTP_FROM_EMAIL", "noreply@pounce.ch"),
|
|
"from_name": os.getenv("SMTP_FROM_NAME", "pounce"),
|
|
"use_tls": os.getenv("SMTP_USE_TLS", "true").lower() == "true",
|
|
}
|
|
|
|
|
|
# Email Templates
|
|
TEMPLATES = {
|
|
"domain_available": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
|
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
|
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
|
.domain { font-family: monospace; font-size: 28px; color: #00d4aa; 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: 16px; }
|
|
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="logo">🐆 pounce</div>
|
|
<h1 style="color: #fff; margin: 0;">Domain Available!</h1>
|
|
<p>Great news! A domain you're monitoring is now available for registration:</p>
|
|
<div class="domain">{{ domain }}</div>
|
|
<p>This domain was previously registered but has now expired or been deleted. Act fast before someone else registers it!</p>
|
|
<a href="{{ register_url }}" class="cta">Register Now →</a>
|
|
<div class="footer">
|
|
<p>You're receiving this because you're monitoring this domain on pounce.</p>
|
|
<p>© {{ year }} pounce. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
|
|
"price_alert": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
|
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
|
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
|
.tld { font-family: monospace; font-size: 24px; color: #00d4aa; }
|
|
.price-change { font-size: 20px; margin: 16px 0; }
|
|
.decrease { color: #00d4aa; }
|
|
.increase { color: #ef4444; }
|
|
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
|
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="logo">🐆 pounce</div>
|
|
<h1 style="color: #fff; margin: 0;">Price Alert: <span class="tld">.{{ tld }}</span></h1>
|
|
<p class="price-change">
|
|
{% if change_percent < 0 %}
|
|
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
|
|
{% else %}
|
|
<span class="increase">↑ Price increased {{ change_percent }}%</span>
|
|
{% endif %}
|
|
</p>
|
|
<p>
|
|
<strong>Old price:</strong> ${{ old_price }}<br>
|
|
<strong>New price:</strong> ${{ new_price }}<br>
|
|
<strong>Cheapest registrar:</strong> {{ registrar }}
|
|
</p>
|
|
<a href="{{ tld_url }}" class="cta">View Details →</a>
|
|
<div class="footer">
|
|
<p>You're receiving this because you set a price alert for .{{ tld }} on pounce.</p>
|
|
<p>© {{ year }} pounce. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
|
|
"subscription_confirmed": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
|
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
|
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
|
.plan { font-size: 28px; color: #00d4aa; margin: 16px 0; }
|
|
.features { background: #252525; padding: 16px; border-radius: 8px; margin: 16px 0; }
|
|
.features li { margin: 8px 0; }
|
|
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
|
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="logo">🐆 pounce</div>
|
|
<h1 style="color: #fff; margin: 0;">Welcome to {{ plan_name }}!</h1>
|
|
<p>Your subscription is now active. Here's what you can do:</p>
|
|
<div class="features">
|
|
<ul>
|
|
{% for feature in features %}
|
|
<li>{{ feature }}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
|
|
<div class="footer">
|
|
<p>Questions? Reply to this email or contact support@pounce.ch</p>
|
|
<p>© {{ year }} pounce. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
|
|
"weekly_digest": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; }
|
|
.container { max-width: 600px; margin: 0 auto; background: #1a1a1a; border-radius: 12px; padding: 32px; }
|
|
.logo { color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }
|
|
.stat { background: #252525; padding: 16px; border-radius: 8px; margin: 8px 0; display: flex; justify-content: space-between; }
|
|
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
|
|
.domain { font-family: monospace; color: #00d4aa; }
|
|
.cta { display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 16px; }
|
|
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #333; color: #888; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="logo">🐆 pounce</div>
|
|
<h1 style="color: #fff; margin: 0;">Your Weekly Digest</h1>
|
|
<p>Here's what happened with your monitored domains this week:</p>
|
|
|
|
<div class="stat">
|
|
<span>Domains Monitored</span>
|
|
<span class="stat-value">{{ total_domains }}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span>Status Changes</span>
|
|
<span class="stat-value">{{ status_changes }}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span>Price Alerts</span>
|
|
<span class="stat-value">{{ price_alerts }}</span>
|
|
</div>
|
|
|
|
{% if available_domains %}
|
|
<h2 style="color: #fff; margin-top: 24px;">Domains Now Available</h2>
|
|
{% for domain in available_domains %}
|
|
<p class="domain">{{ domain }}</p>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
|
|
<div class="footer">
|
|
<p>© {{ year }} pounce. All rights reserved.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""",
|
|
}
|
|
|
|
|
|
class EmailService:
|
|
"""
|
|
Async email service using SMTP.
|
|
|
|
All emails use HTML templates with the pounce branding.
|
|
"""
|
|
|
|
@staticmethod
|
|
def is_configured() -> bool:
|
|
"""Check if SMTP is properly configured."""
|
|
return bool(
|
|
SMTP_CONFIG["host"] and
|
|
SMTP_CONFIG["username"] and
|
|
SMTP_CONFIG["password"]
|
|
)
|
|
|
|
@staticmethod
|
|
async def send_email(
|
|
to_email: str,
|
|
subject: str,
|
|
html_content: str,
|
|
text_content: Optional[str] = None,
|
|
) -> bool:
|
|
"""
|
|
Send an email via SMTP.
|
|
|
|
Args:
|
|
to_email: Recipient email address
|
|
subject: Email subject
|
|
html_content: HTML body
|
|
text_content: Plain text body (optional, for email clients that don't support HTML)
|
|
|
|
Returns:
|
|
True if sent successfully, False otherwise
|
|
"""
|
|
if not EmailService.is_configured():
|
|
logger.warning(f"SMTP not configured. Would send to {to_email}: {subject}")
|
|
return False
|
|
|
|
try:
|
|
# Create message
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{SMTP_CONFIG['from_name']} <{SMTP_CONFIG['from_email']}>"
|
|
msg["To"] = to_email
|
|
|
|
# Add text part (fallback)
|
|
if text_content:
|
|
msg.attach(MIMEText(text_content, "plain"))
|
|
|
|
# Add HTML part
|
|
msg.attach(MIMEText(html_content, "html"))
|
|
|
|
# Send via SMTP
|
|
async with aiosmtplib.SMTP(
|
|
hostname=SMTP_CONFIG["host"],
|
|
port=SMTP_CONFIG["port"],
|
|
use_tls=SMTP_CONFIG["use_tls"],
|
|
) as smtp:
|
|
await smtp.login(SMTP_CONFIG["username"], SMTP_CONFIG["password"])
|
|
await smtp.send_message(msg)
|
|
|
|
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
|
|
|
|
@staticmethod
|
|
async def send_domain_available(
|
|
to_email: str,
|
|
domain: str,
|
|
register_url: Optional[str] = None,
|
|
) -> bool:
|
|
"""Send domain available notification."""
|
|
if not register_url:
|
|
register_url = f"https://pounce.ch/dashboard"
|
|
|
|
template = Template(TEMPLATES["domain_available"])
|
|
html = template.render(
|
|
domain=domain,
|
|
register_url=register_url,
|
|
year=datetime.utcnow().year,
|
|
)
|
|
|
|
return await EmailService.send_email(
|
|
to_email=to_email,
|
|
subject=f"🎉 Domain Available: {domain}",
|
|
html_content=html,
|
|
text_content=f"Great news! {domain} is now available for registration. Visit {register_url} to register.",
|
|
)
|
|
|
|
@staticmethod
|
|
async def send_price_alert(
|
|
to_email: str,
|
|
tld: str,
|
|
old_price: float,
|
|
new_price: float,
|
|
registrar: str,
|
|
) -> bool:
|
|
"""Send TLD price change alert."""
|
|
change_percent = round(((new_price - old_price) / old_price) * 100, 1)
|
|
|
|
template = Template(TEMPLATES["price_alert"])
|
|
html = template.render(
|
|
tld=tld,
|
|
old_price=f"{old_price:.2f}",
|
|
new_price=f"{new_price:.2f}",
|
|
change_percent=change_percent,
|
|
registrar=registrar,
|
|
tld_url=f"https://pounce.ch/tld-pricing/{tld}",
|
|
year=datetime.utcnow().year,
|
|
)
|
|
|
|
direction = "dropped" if change_percent < 0 else "increased"
|
|
return await EmailService.send_email(
|
|
to_email=to_email,
|
|
subject=f"📊 Price Alert: .{tld} {direction} {abs(change_percent)}%",
|
|
html_content=html,
|
|
text_content=f".{tld} price {direction} from ${old_price:.2f} to ${new_price:.2f} at {registrar}.",
|
|
)
|
|
|
|
@staticmethod
|
|
async def send_subscription_confirmed(
|
|
to_email: str,
|
|
plan_name: str,
|
|
features: List[str],
|
|
) -> bool:
|
|
"""Send subscription confirmation email."""
|
|
template = Template(TEMPLATES["subscription_confirmed"])
|
|
html = template.render(
|
|
plan_name=plan_name,
|
|
features=features,
|
|
dashboard_url="https://pounce.ch/dashboard",
|
|
year=datetime.utcnow().year,
|
|
)
|
|
|
|
return await EmailService.send_email(
|
|
to_email=to_email,
|
|
subject=f"✅ Welcome to pounce {plan_name}!",
|
|
html_content=html,
|
|
text_content=f"Your {plan_name} subscription is now active. Visit https://pounce.ch/dashboard to get started.",
|
|
)
|
|
|
|
@staticmethod
|
|
async def send_weekly_digest(
|
|
to_email: str,
|
|
total_domains: int,
|
|
status_changes: int,
|
|
price_alerts: int,
|
|
available_domains: List[str],
|
|
) -> bool:
|
|
"""Send weekly summary digest."""
|
|
template = Template(TEMPLATES["weekly_digest"])
|
|
html = template.render(
|
|
total_domains=total_domains,
|
|
status_changes=status_changes,
|
|
price_alerts=price_alerts,
|
|
available_domains=available_domains,
|
|
dashboard_url="https://pounce.ch/dashboard",
|
|
year=datetime.utcnow().year,
|
|
)
|
|
|
|
return await EmailService.send_email(
|
|
to_email=to_email,
|
|
subject=f"📬 Your pounce Weekly Digest",
|
|
html_content=html,
|
|
text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
|
|
)
|
|
|
|
|
|
# Global instance
|
|
email_service = EmailService()
|