""" 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 = """
A domain you're tracking just dropped:
It's available right now. Move fastβothers are watching too.
Grab It Now βYou're tracking this domain on POUNCE.
""", "price_alert": """{% if change_percent < 0 %} β Down {{ change_percent|abs }}% {% else %} β Up {{ change_percent }}% {% endif %}
Was: ${{ old_price }}
Now: ${{ new_price }}
Cheapest at: {{ registrar }}
You set an alert for .{{ tld }} on POUNCE.
""", "subscription_confirmed": """Your hunting arsenal just upgraded:
Questions? Just reply to this email.
""", "weekly_digest": """Here's what moved while you were busy:
{{ domain }}
{% endfor %}Hey {{ user_name }},
Someone requested a password reset. If that was you, click below:
Reset Password βOr copy this link:
{{ reset_url }}
Link expires in 1 hour.
Didn't request this? Ignore it. Nothing changes.
""", "email_verification": """Hey {{ user_name }},
Welcome to POUNCE. Verify your email to activate your account:
Verify & Start βOr copy this link:
{{ verification_url }}
Link expires in 24 hours.
Didn't sign up? Just ignore this.
""", "contact_form": """From: {{ name }} <{{ email }}>
Subject: {{ subject }}
Date: {{ timestamp }}
{{ message }}
Hey {{ name }},
Your message landed. We'll get back to you soon.
Subject: {{ subject }}
Your message:
{{ message }}
Expect a reply within 24-48 hours.
Back to POUNCE β """, "newsletter_welcome": """Welcome to POUNCE Insights.
Here's what you'll get:
1-2 emails per month. No spam. Ever.
Start Exploring βUnsubscribe anytime with one click.
""", } 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()