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
🎯 BRAND VOICE: Swift, Sharp, Strategic, Confident
LANDING PAGE:
- Hero: 'Others wait. You pounce.'
- Features: Active, punchy descriptions
- Pricing: 'Pick your weapon.'
- CTAs: 'Hunt Free', 'Start Trading', 'Go Tycoon'
PRICING PAGE:
- Scout: 'Test the waters. Zero risk.'
- Trader: 'Hunt with precision.'
- Tycoon: 'Dominate the market.'
AUTH PAGES:
- Login: 'Back to the hunt.'
- Register: 'Join the hunt.'
- Benefits: 'Your hunting gear. Ready to go.'
EMAIL TEMPLATES:
- Domain Available: 'Time to pounce. [domain] just dropped'
- Price Alert: '.tld just moved.'
- Weekly Digest: 'Your week in domains.'
- Subscription: 'You're in. [Plan] unlocked.'
DASHBOARD:
- Title: 'Command Center'
- Empty State: 'No targets yet'
TLD PAGES:
- Headline: '886+ TLDs. Live Prices.'
- Section: 'Moving Now' instead of 'Trending'
CONTACT & ABOUT:
- About: 'Built for hunters. By hunters.'
- Mission: 'Level the playing field.'
- Contact: 'Let's Talk' - direct, confident
All text now follows the hunter metaphor with:
- Active verbs (Track, Hunt, Pounce, Grab)
- Urgency without panic
- Confident statements
- Data as proof
- Short, punchy sentences
633 lines
19 KiB
Python
633 lines
19 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>Time to pounce.</h1>
|
|
<p>A domain you're tracking just dropped:</p>
|
|
<div class="highlight">{{ domain }}</div>
|
|
<p>It's available right now. Move fast—others are watching too.</p>
|
|
<a href="{{ register_url }}" class="cta">Grab It Now →</a>
|
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
|
You're tracking this domain on POUNCE.
|
|
</p>
|
|
""",
|
|
|
|
"price_alert": """
|
|
<h1>.{{ tld }} just moved.</h1>
|
|
<p style="font-size: 20px;">
|
|
{% if change_percent < 0 %}
|
|
<span class="decrease">↓ Down {{ change_percent|abs }}%</span>
|
|
{% else %}
|
|
<span class="increase">↑ Up {{ change_percent }}%</span>
|
|
{% endif %}
|
|
</p>
|
|
<div class="info-box">
|
|
<p><strong>Was:</strong> ${{ old_price }}</p>
|
|
<p><strong>Now:</strong> ${{ new_price }}</p>
|
|
<p><strong>Cheapest at:</strong> {{ registrar }}</p>
|
|
</div>
|
|
<a href="{{ tld_url }}" class="cta">See Details →</a>
|
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
|
You set an alert for .{{ tld }} on POUNCE.
|
|
</p>
|
|
""",
|
|
|
|
"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": """
|
|
<h1>Reset your password.</h1>
|
|
<p>Hey {{ user_name }},</p>
|
|
<p>Someone requested a password reset. If that was you, click below:</p>
|
|
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
|
|
<p style="margin-top: 24px;">Or copy this link:</p>
|
|
<code style="word-break: break-all;">{{ reset_url }}</code>
|
|
<div class="info-box" style="margin-top: 24px;">
|
|
<p class="warning" style="margin: 0;">Link expires in 1 hour.</p>
|
|
</div>
|
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
|
Didn't request this? Ignore it. Nothing changes.
|
|
</p>
|
|
""",
|
|
|
|
"email_verification": """
|
|
<h1>One click to start hunting.</h1>
|
|
<p>Hey {{ user_name }},</p>
|
|
<p>Welcome to POUNCE. Verify your email to activate your account:</p>
|
|
<a href="{{ verification_url }}" class="cta">Verify & Start →</a>
|
|
<p style="margin-top: 24px;">Or copy this link:</p>
|
|
<code style="word-break: break-all;">{{ verification_url }}</code>
|
|
<div class="info-box" style="margin-top: 24px;">
|
|
<p style="margin: 0;">Link expires in 24 hours.</p>
|
|
</div>
|
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
|
Didn't sign up? Just ignore this.
|
|
</p>
|
|
""",
|
|
|
|
"contact_form": """
|
|
<h1>New message from the wild.</h1>
|
|
<div class="info-box">
|
|
<p><strong>From:</strong> {{ name }} <{{ email }}></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 →</a>
|
|
</p>
|
|
""",
|
|
|
|
"contact_confirmation": """
|
|
<h1>Got it.</h1>
|
|
<p>Hey {{ name }},</p>
|
|
<p>Your message landed. We'll get back to you soon.</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>Expect a reply within 24-48 hours.</p>
|
|
<a href="https://pounce.ch" class="secondary-cta">Back to POUNCE →</a>
|
|
""",
|
|
|
|
"newsletter_welcome": """
|
|
<h1>You're on the list.</h1>
|
|
<p>Welcome to POUNCE Insights.</p>
|
|
<p>Here's what you'll get:</p>
|
|
<div class="info-box">
|
|
<ul>
|
|
<li>TLD market moves & analysis</li>
|
|
<li>Domain investing strategies</li>
|
|
<li>New feature drops</li>
|
|
<li>Exclusive deals</li>
|
|
</ul>
|
|
</div>
|
|
<p>1-2 emails per month. No spam. Ever.</p>
|
|
<a href="https://pounce.ch" class="cta">Start Exploring →</a>
|
|
<p style="margin-top: 24px; color: #888; font-size: 14px;">
|
|
Unsubscribe anytime with one click.
|
|
</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"🐆 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()
|