pounce/backend/app/services/email_service.py
yves.gugger 88eca582e5 feat: Remove ALL mock data - real scraped data only
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
2025-12-08 14:08:52 +01:00

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()