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
- 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
588 lines
21 KiB
Python
588 lines
21 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")
|
||
|
||
|
||
# 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 — 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 }} <{{ email }}></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 24–48 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. 1–2 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()
|