pounce/backend/app/services/email_service.py
Yves Gugger 0582b26be7
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
feat: Add user deletion in admin panel and fix OAuth authentication
- Add delete user functionality with cascade deletion of all user data
- Fix OAuth URLs to include /api/v1 path
- Fix token storage key consistency in OAuth callback
- Update user model to cascade delete price alerts
- Improve email templates with minimalist design
- Add confirmation dialog for user deletion
- Prevent deletion of admin users
2025-12-09 21:45:40 +01:00

588 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Email Service for pounce.ch
Sends transactional emails using SMTP.
Email Types:
- Domain availability alerts
- Price change notifications
- Subscription confirmations
- Password reset
- Email verification
- Contact form messages
- 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
from typing import Optional, List
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 (Zoho defaults)
SMTP_CONFIG = {
"host": os.getenv("SMTP_HOST", "smtp.zoho.eu"),
"port": int(os.getenv("SMTP_PORT", "465")),
"username": os.getenv("SMTP_USER", "hello@pounce.ch"),
"password": os.getenv("SMTP_PASSWORD"),
"from_email": os.getenv("SMTP_FROM_EMAIL", "hello@pounce.ch"),
"from_name": os.getenv("SMTP_FROM_NAME", "pounce"),
"use_tls": os.getenv("SMTP_USE_TLS", "false").lower() == "true",
"use_ssl": os.getenv("SMTP_USE_SSL", "true").lower() == "true",
}
# Contact email - where contact form submissions are sent
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "hello@pounce.ch")
# Minimalistic Professional Email Template
BASE_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<div style="max-width: 580px; margin: 40px auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="padding: 32px 40px; border-bottom: 1px solid #e5e5e5;">
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #000000; letter-spacing: -0.5px;">
pounce
</h1>
</div>
<!-- Content -->
<div style="padding: 40px;">
{{ content }}
</div>
<!-- Footer -->
<div style="padding: 24px 40px; background: #fafafa; border-top: 1px solid #e5e5e5;">
<p style="margin: 0; font-size: 13px; color: #666666; line-height: 1.6;">
pounce &mdash; Domain Intelligence Platform<br>
<a href="https://pounce.ch" style="color: #000000; text-decoration: none;">pounce.ch</a>
</p>
</div>
</div>
</body>
</html>
"""
# Email Templates (content only, wrapped in BASE_TEMPLATE)
TEMPLATES = {
"domain_available": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Domain available
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
A domain you're monitoring is now available:
</p>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px; border-left: 3px solid #000000;\">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #000000; font-family: monospace;\">
{{ domain }}
</p>
</div>
<div style="margin: 32px 0 0 0;\">
<a href="{{ register_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
Register Domain
</a>
</div>
""",
"price_alert": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Price alert: .{{ tld }}
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
The price for .{{ tld }} has changed:
</p>
<div style="margin: 24px 0; padding: 24px; background: #fafafa; border-radius: 6px;\">
<div style="margin-bottom: 16px;\">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">Previous Price</p>
<p style="margin: 0; font-size: 18px; color: #999999; text-decoration: line-through;\">\${{ old_price }}</p>
</div>
<div style="margin-bottom: 16px;\">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">New Price</p>
<p style="margin: 0; font-size: 24px; font-weight: 600; color: #000000;\">\${{ new_price }}</p>
</div>
<p style="margin: 16px 0 0 0; font-size: 14px; {% if change_percent < 0 %}color: #10b981;{% else %}color: #ef4444;{% endif %}\">
{% if change_percent < 0 %}↓{% else %}↑{% endif %} {{ change_percent|abs }}%
</p>
</div>
<p style="margin: 24px 0; font-size: 14px; color: #666666;\">
Cheapest at: <strong style="color: #000000;\">{{ registrar }}</strong>
</p>
<div style="margin: 32px 0 0 0;\">
<a href="{{ tld_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
View Details
</a>
</div>
""",
"subscription_confirmed": """
<h1>You're in. {{ plan_name }} unlocked.</h1>
<p>Your hunting arsenal just upgraded:</p>
<div class="info-box">
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ dashboard_url }}" class="cta">Start Hunting →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Questions? Just reply to this email.
</p>
""",
"weekly_digest": """
<h1>Your week in domains.</h1>
<p>Here's what moved while you were busy:</p>
<div class="stat">
<span>Domains Tracked</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 Moves</span>
<span class="stat-value">{{ price_alerts }}</span>
</div>
{% if available_domains %}
<h2>Dropped This Week</h2>
<div class="info-box">
{% for domain in available_domains %}
<p class="highlight" style="margin: 8px 0;">{{ domain }}</p>
{% endfor %}
</div>
{% endif %}
<a href="{{ dashboard_url }}" class="cta">View Dashboard →</a>
""",
"password_reset": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Reset your password
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ user_name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
We received a request to reset your password. Click the button below to create a new password.
</p>
<div style="margin: 0 0 32px 0;">
<a href="{{ reset_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Reset Password
</a>
</div>
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
This link expires in 1 hour.
</p>
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
"email_verification": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Verify your email
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ user_name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Thanks for signing up. Click the button below to verify your email and activate your account.
</p>
<div style="margin: 0 0 32px 0;">
<a href="{{ verification_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Verify Email
</a>
</div>
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
This link expires in 24 hours.
</p>
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
If you didn't sign up, you can safely ignore this email.
</p>
""",
"contact_form": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
New Contact Form Submission
</h2>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;\">
<p style="margin: 0 0 12px 0; font-size: 14px; color: #666666;\">From</p>
<p style="margin: 0 0 16px 0; font-size: 15px; color: #000000;\">{{ name }} &lt;{{ email }}&gt;</p>
<p style="margin: 16px 0 12px 0; font-size: 14px; color: #666666;\">Subject</p>
<p style="margin: 0; font-size: 15px; color: #000000;\">{{ subject }}</p>
</div>
<p style="margin: 24px 0 12px 0; font-size: 14px; color: #666666;\">Message</p>
<p style="margin: 0; font-size: 15px; color: #333333; line-height: 1.6; white-space: pre-wrap;\">{{ message }}</p>
<div style="margin: 32px 0 0 0;\">
<a href="mailto:{{ email }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
Reply
</a>
</div>
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999;\">Sent: {{ timestamp }}</p>
""",
"contact_confirmation": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Message received
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Thanks for reaching out. We've received your message and will get back to you within 2448 hours.
</p>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #666666;">Your message</p>
<p style="margin: 0; font-size: 14px; color: #999999; white-space: pre-wrap;">{{ message }}</p>
</div>
""",
"newsletter_welcome": """
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Welcome to pounce insights
</h2>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
You'll receive updates about TLD market trends, domain investment strategies, and new features. 12 emails per month. No spam.
</p>
<div style="margin: 32px 0 0 0;">
<a href="https://pounce.ch" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Visit pounce.ch
</a>
</div>
""",
}
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
def _render_email(template_name: str, **kwargs) -> str:
"""Render email with base template wrapper."""
content_template = Template(TEMPLATES.get(template_name, ""))
content = content_template.render(**kwargs)
base_template = Template(BASE_TEMPLATE)
return base_template.render(
content=content,
year=datetime.utcnow().year,
)
@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)
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
# Zoho uses SSL on port 465 (not STARTTLS on 587)
if SMTP_CONFIG["use_ssl"]:
# SSL connection (port 465)
async with aiosmtplib.SMTP(
hostname=SMTP_CONFIG["host"],
port=SMTP_CONFIG["port"],
use_tls=True, # Start with TLS/SSL
start_tls=False, # Don't upgrade, already encrypted
) as smtp:
await smtp.login(SMTP_CONFIG["username"], SMTP_CONFIG["password"])
await smtp.send_message(msg)
else:
# STARTTLS connection (port 587)
async with aiosmtplib.SMTP(
hostname=SMTP_CONFIG["host"],
port=SMTP_CONFIG["port"],
start_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
# ============== Domain Alerts ==============
@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 = "https://pounce.ch/dashboard"
html = EmailService._render_email(
"domain_available",
domain=domain,
register_url=register_url,
)
return await EmailService.send_email(
to_email=to_email,
subject=f"🐆 POUNCE NOW: {domain} just dropped",
html_content=html,
text_content=f"{domain} is available! Grab it now: {register_url}",
)
@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)
html = EmailService._render_email(
"price_alert",
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}",
)
direction = "down" if change_percent < 0 else "up"
return await EmailService.send_email(
to_email=to_email,
subject=f"💰 .{tld} moved {direction} {abs(change_percent)}%",
html_content=html,
text_content=f".{tld} is now ${new_price:.2f} (was ${old_price:.2f}) at {registrar}.",
)
# ============== Subscription ==============
@staticmethod
async def send_subscription_confirmed(
to_email: str,
plan_name: str,
features: List[str],
) -> bool:
"""Send subscription confirmation email."""
html = EmailService._render_email(
"subscription_confirmed",
plan_name=plan_name,
features=features,
dashboard_url="https://pounce.ch/dashboard",
)
return await EmailService.send_email(
to_email=to_email,
subject=f"🎯 {plan_name} unlocked. Let's hunt.",
html_content=html,
text_content=f"Your {plan_name} plan is active. Start hunting: https://pounce.ch/dashboard",
)
# ============== Digest ==============
@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."""
html = EmailService._render_email(
"weekly_digest",
total_domains=total_domains,
status_changes=status_changes,
price_alerts=price_alerts,
available_domains=available_domains,
dashboard_url="https://pounce.ch/dashboard",
)
return await EmailService.send_email(
to_email=to_email,
subject="📊 Your week in domains",
html_content=html,
text_content=f"This week: {total_domains} domains monitored, {status_changes} status changes, {price_alerts} price alerts.",
)
# ============== Authentication ==============
@staticmethod
async def send_password_reset(
to_email: str,
user_name: str,
reset_url: str,
) -> bool:
"""Send password reset email."""
html = EmailService._render_email(
"password_reset",
user_name=user_name,
reset_url=reset_url,
)
return await EmailService.send_email(
to_email=to_email,
subject="Reset your POUNCE password",
html_content=html,
text_content=f"Reset your password: {reset_url} (expires in 1 hour)",
)
@staticmethod
async def send_email_verification(
to_email: str,
user_name: str,
verification_url: str,
) -> bool:
"""Send email verification email."""
html = EmailService._render_email(
"email_verification",
user_name=user_name,
verification_url=verification_url,
)
return await EmailService.send_email(
to_email=to_email,
subject="Verify your email. Start hunting.",
html_content=html,
text_content=f"Verify your POUNCE account: {verification_url}",
)
# ============== Contact Form ==============
@staticmethod
async def send_contact_form(
name: str,
email: str,
subject: str,
message: str,
) -> bool:
"""
Send contact form submission to support.
Also sends confirmation to the user.
"""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
# Send to support
support_html = EmailService._render_email(
"contact_form",
name=name,
email=email,
subject=subject,
message=message,
timestamp=timestamp,
)
support_sent = await EmailService.send_email(
to_email=CONTACT_EMAIL,
subject=f"[Contact] {subject} - from {name}",
html_content=support_html,
text_content=f"From: {name} <{email}>\nSubject: {subject}\n\n{message}",
)
# Send confirmation to user
confirm_html = EmailService._render_email(
"contact_confirmation",
name=name,
subject=subject,
message=message,
)
confirm_sent = await EmailService.send_email(
to_email=email,
subject="Got your message. We'll be in touch.",
html_content=confirm_html,
text_content=f"Hey {name}, we received your message. Expect a reply within 24-48 hours.",
)
return support_sent # Return whether support email was sent
# ============== Newsletter ==============
@staticmethod
async def send_newsletter_welcome(
to_email: str,
) -> bool:
"""Send newsletter subscription welcome email."""
html = EmailService._render_email("newsletter_welcome")
return await EmailService.send_email(
to_email=to_email,
subject="You're on the list. Welcome to POUNCE.",
html_content=html,
text_content="Welcome to POUNCE Insights. Expect market moves, strategies, and feature drops. No spam.",
)
# Global instance
email_service = EmailService()