pounce/backend/app/services/email_service.py
yves.gugger 225d720e8a
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: Configure all email services to use Zoho Mail
Email Service (email_service.py):
- Updated SMTP defaults for Zoho (smtp.zoho.eu:465)
- Added SSL support (use_ssl parameter) for port 465
- Updated send_email() to handle both SSL (port 465) and STARTTLS (port 587)
- Changed default sender from noreply@pounce.ch to hello@pounce.ch
- Updated contact email to hello@pounce.ch

Configuration Files:
- env.example: Complete Zoho configuration with SSL
- env.example.txt: Updated with Zoho defaults
- README.md: Updated email setup documentation with Zoho instructions

Zoho Configuration:
- Host: smtp.zoho.eu
- Port: 465 (SSL)
- SSL: true, TLS: false
- Sender: hello@pounce.ch

Supported Email Types:
- Domain availability alerts
- Price change notifications
- Subscription confirmations
- Password reset
- Email verification
- Contact form (send + confirmation)
- Newsletter welcome
- Weekly digests
2025-12-08 15:41:05 +01:00

634 lines
20 KiB
Python

"""
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")
# Base email wrapper template
BASE_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
padding: 20px;
margin: 0;
}
.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;
}
h1 { color: #fff; margin: 0 0 16px 0; }
h2 { color: #fff; margin: 24px 0 16px 0; }
p { color: #e5e5e5; line-height: 1.6; }
.highlight {
font-family: monospace;
font-size: 24px;
color: #00d4aa;
margin: 16px 0;
}
.cta {
display: inline-block;
background: #00d4aa;
color: #0a0a0a;
padding: 14px 28px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
margin-top: 16px;
}
.cta:hover { background: #00c49a; }
.secondary-cta {
display: inline-block;
background: transparent;
color: #00d4aa;
padding: 12px 24px;
border-radius: 8px;
border: 1px solid #00d4aa;
text-decoration: none;
font-weight: 500;
margin-top: 16px;
margin-left: 8px;
}
.info-box {
background: #252525;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
.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; }
.warning { color: #f59e0b; }
.success { color: #00d4aa; }
.decrease { color: #00d4aa; }
.increase { color: #ef4444; }
.footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #333;
color: #888;
font-size: 12px;
}
.footer a { color: #00d4aa; text-decoration: none; }
ul { padding-left: 20px; }
li { margin: 8px 0; }
code {
background: #252525;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #00d4aa;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
{{ content }}
<div class="footer">
<p>© {{ year }} pounce. All rights reserved.</p>
<p>
<a href="https://pounce.ch">pounce.ch</a> ·
<a href="https://pounce.ch/privacy">Privacy</a> ·
<a href="https://pounce.ch/terms">Terms</a>
</p>
</div>
</div>
</body>
</html>
"""
# Email Templates (content only, wrapped in BASE_TEMPLATE)
TEMPLATES = {
"domain_available": """
<h1>Domain Available!</h1>
<p>Great news! A domain you're monitoring is now available for registration:</p>
<div class="highlight">{{ 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>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're receiving this because you're monitoring this domain on pounce.
</p>
""",
"price_alert": """
<h1>Price Alert: <span style="color: #00d4aa;">.{{ tld }}</span></h1>
<p style="font-size: 20px;">
{% if change_percent < 0 %}
<span class="decrease">↓ Price dropped {{ change_percent|abs }}%</span>
{% else %}
<span class="increase">↑ Price increased {{ change_percent }}%</span>
{% endif %}
</p>
<div class="info-box">
<p><strong>Old price:</strong> ${{ old_price }}</p>
<p><strong>New price:</strong> ${{ new_price }}</p>
<p><strong>Cheapest registrar:</strong> {{ registrar }}</p>
</div>
<a href="{{ tld_url }}" class="cta">View Details →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're receiving this because you set a price alert for .{{ tld }} on pounce.
</p>
""",
"subscription_confirmed": """
<h1>Welcome to {{ plan_name }}!</h1>
<p>Your subscription is now active. Here's what you can do:</p>
<div class="info-box">
<ul>
{% for feature in features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
</div>
<a href="{{ dashboard_url }}" class="cta">Go to Dashboard →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Questions? Reply to this email or contact support@pounce.ch
</p>
""",
"weekly_digest": """
<h1>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>Domains Now Available</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": """
<h1>Reset Your Password</h1>
<p>Hi {{ user_name }},</p>
<p>We received a request to reset your password. Click the button below to set a new password:</p>
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
<code style="word-break: break-all;">{{ reset_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p class="warning" style="margin: 0;">⚠️ This link expires in 1 hour.</p>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
If you didn't request a password reset, you can safely ignore this email.
Your password won't be changed.
</p>
""",
"email_verification": """
<h1>Verify Your Email</h1>
<p>Hi {{ user_name }},</p>
<p>Welcome to pounce! Please verify your email address to activate your account:</p>
<a href="{{ verification_url }}" class="cta">Verify Email →</a>
<p style="margin-top: 24px;">Or copy and paste this link into your browser:</p>
<code style="word-break: break-all;">{{ verification_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p style="margin: 0;">This link expires in 24 hours.</p>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
If you didn't create an account on pounce, you can safely ignore this email.
</p>
""",
"contact_form": """
<h1>New Contact Form Submission</h1>
<div class="info-box">
<p><strong>From:</strong> {{ name }} &lt;{{ email }}&gt;</p>
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Date:</strong> {{ timestamp }}</p>
</div>
<h2>Message</h2>
<div class="info-box">
<p style="white-space: pre-wrap;">{{ message }}</p>
</div>
<p style="margin-top: 24px;">
<a href="mailto:{{ email }}" class="cta">Reply to {{ name }} →</a>
</p>
""",
"contact_confirmation": """
<h1>We've Received Your Message</h1>
<p>Hi {{ name }},</p>
<p>Thank you for contacting pounce! We've received your message and will get back to you as soon as possible.</p>
<div class="info-box">
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Your message:</strong></p>
<p style="white-space: pre-wrap; color: #888;">{{ message }}</p>
</div>
<p>We typically respond within 24-48 hours during business days.</p>
<a href="https://pounce.ch" class="secondary-cta">Back to pounce →</a>
""",
"newsletter_welcome": """
<h1>Welcome to pounce Insights!</h1>
<p>Hi there,</p>
<p>You're now subscribed to our newsletter. Here's what you can expect:</p>
<div class="info-box">
<ul>
<li>TLD market trends and analysis</li>
<li>Domain investing tips and strategies</li>
<li>New feature announcements</li>
<li>Exclusive deals and discounts</li>
</ul>
</div>
<p>We typically send 1-2 emails per month. No spam, ever.</p>
<a href="https://pounce.ch" class="cta">Explore pounce →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You can unsubscribe at any time by clicking the link at the bottom of any email.
</p>
""",
}
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"🎉 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)
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 = "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}.",
)
# ============== 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"✅ 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.",
)
# ============== 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 pounce Weekly Digest",
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"Hi {user_name}, reset your password by visiting: {reset_url}. This link 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 pounce Email",
html_content=html,
text_content=f"Hi {user_name}, verify your email by visiting: {verification_url}. This link expires in 24 hours.",
)
# ============== 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="We've received your message - pounce",
html_content=confirm_html,
text_content=f"Hi {name}, we've received your message and will get back to you soon.",
)
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="🎉 Welcome to pounce Insights!",
html_content=html,
text_content="Welcome to pounce Insights! You'll receive TLD market trends, domain investing tips, and feature announcements.",
)
# Global instance
email_service = EmailService()