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
304 lines
12 KiB
Python
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()
|
|
|