pounce/backend/app/services/email_service.py
yves.gugger fb080375a0 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
2025-12-08 09:27:03 +01:00

304 lines
12 KiB
Python

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