feat: Mailgun email integration with SES fallback
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

- Mailgun API as primary email provider
- AWS SES kept as fallback
- Improved email templates with modern styling
- Environment vars: MAILGUN_API_KEY, MAILGUN_DOMAIN
This commit is contained in:
fullsizemalt 2025-12-23 15:49:33 -08:00
parent 1f7f83a31a
commit 68453d6865
2 changed files with 354 additions and 64 deletions

View file

@ -1,69 +1,126 @@
"""
Email Service - Mailgun (primary) with AWS SES fallback
"""
import os import os
import boto3 import httpx
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from botocore.exceptions import ClientError
from typing import Optional from typing import Optional
# Optional: Keep boto3 for legacy/fallback
try:
import boto3
from botocore.exceptions import ClientError
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
class EmailService: class EmailService:
def __init__(self): def __init__(self):
self.region_name = os.getenv("AWS_SES_REGION", "us-east-1") # Mailgun settings (primary)
self.mailgun_api_key = os.getenv("MAILGUN_API_KEY")
self.mailgun_domain = os.getenv("MAILGUN_DOMAIN")
self.mailgun_api_base = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3")
# AWS SES settings (fallback)
self.aws_region = os.getenv("AWS_SES_REGION", "us-east-1")
self.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID") self.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID")
self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY") self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY")
# Common settings
self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz") self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz") self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
# Initialize SES client if credentials exist # Determine which provider to use
if self.aws_access_key_id and self.aws_secret_access_key: if self.mailgun_api_key and self.mailgun_domain:
self.client = boto3.client( self.provider = "mailgun"
print(f"Email service: Using Mailgun ({self.mailgun_domain})")
elif BOTO3_AVAILABLE and self.aws_access_key_id and self.aws_secret_access_key:
self.provider = "ses"
self.ses_client = boto3.client(
"ses", "ses",
region_name=self.region_name, region_name=self.aws_region,
aws_access_key_id=self.aws_access_key_id, aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key, aws_secret_access_key=self.aws_secret_access_key,
) )
print("Email service: Using AWS SES")
else: else:
self.client = None self.provider = "dummy"
print("WARNING: AWS credentials not found. Email service running in dummy mode.") print("WARNING: No email credentials found. Running in dummy mode.")
def send_email(self, to_email: str, subject: str, html_content: str, text_content: str): def send_email(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send an email using AWS SES""" """Send an email using configured provider"""
if not self.client: if self.provider == "mailgun":
print(f"DUMMY EMAIL to {to_email}: {subject}") return self._send_mailgun(to_email, subject, html_content, text_content)
print(text_content) elif self.provider == "ses":
return True return self._send_ses(to_email, subject, html_content, text_content)
else:
return self._send_dummy(to_email, subject, text_content)
def _send_mailgun(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send email via Mailgun API"""
try: try:
response = self.client.send_email( with httpx.Client() as client:
response = client.post(
f"{self.mailgun_api_base}/{self.mailgun_domain}/messages",
auth=("api", self.mailgun_api_key),
data={
"from": f"Elmeg <{self.email_from}>",
"to": to_email,
"subject": subject,
"text": text_content,
"html": html_content,
},
timeout=30.0,
)
if response.status_code == 200:
print(f"Email sent via Mailgun to {to_email}")
return response.json()
else:
print(f"Mailgun error: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"Error sending email via Mailgun: {e}")
return False
def _send_ses(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send email via AWS SES (fallback)"""
try:
response = self.ses_client.send_email(
Source=self.email_from, Source=self.email_from,
Destination={ Destination={"ToAddresses": [to_email]},
"ToAddresses": [to_email],
},
Message={ Message={
"Subject": { "Subject": {"Data": subject, "Charset": "UTF-8"},
"Data": subject,
"Charset": "UTF-8",
},
"Body": { "Body": {
"Html": { "Html": {"Data": html_content, "Charset": "UTF-8"},
"Data": html_content, "Text": {"Data": text_content, "Charset": "UTF-8"},
"Charset": "UTF-8",
},
"Text": {
"Data": text_content,
"Charset": "UTF-8",
},
}, },
}, },
) )
print(f"Email sent via SES to {to_email}")
return response return response
except ClientError as e: except ClientError as e:
print(f"Error sending email: {e.response['Error']['Message']}") print(f"SES error: {e.response['Error']['Message']}")
return False return False
def _send_dummy(self, to_email: str, subject: str, text_content: str):
"""Dummy mode - print to console"""
print(f"\n{'='*50}")
print(f"DUMMY EMAIL to: {to_email}")
print(f"Subject: {subject}")
print("-" * 50)
print(text_content)
print(f"{'='*50}\n")
return True
# Global instance # Global instance
email_service = EmailService() email_service = EmailService()
# --- Helper Functions (used by auth router) ---
# --- Helper Functions ---
def generate_token() -> str: def generate_token() -> str:
"""Generate a secure random token""" """Generate a secure random token"""
@ -85,17 +142,17 @@ def send_verification_email(to_email: str, token: str):
html_content = f""" html_content = f"""
<html> <html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;"> <div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #2563eb;">Welcome to Elmeg!</h2> <h2 style="color: #2563eb; margin-top: 0;">Welcome to Elmeg! 🎵</h2>
<p>Thanks for signing up. Please verify your email address to get started.</p> <p>Thanks for joining the community. Please verify your email address to get started.</p>
<div style="margin: 30px 0;"> <div style="margin: 30px 0; text-align: center;">
<a href="{verify_url}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Verify Email Address</a> <a href="{verify_url}" style="background: linear-gradient(135deg, #2563eb, #4f46e5); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: 600; display: inline-block;">Verify Email Address</a>
</div> </div>
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p> <p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
<p style="font-size: 12px; color: #666;">{verify_url}</p> <p style="font-size: 12px; color: #888; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">{verify_url}</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;"> <hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #999;">If you didn't create an account, you can safely ignore this email.</p> <p style="font-size: 12px; color: #999; margin-bottom: 0;">If you didn't create an account, you can safely ignore this email.</p>
</div> </div>
</body> </body>
</html> </html>
@ -120,18 +177,18 @@ def send_password_reset_email(to_email: str, token: str):
html_content = f""" html_content = f"""
<html> <html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;"> <div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #2563eb;">Password Reset Request</h2> <h2 style="color: #2563eb; margin-top: 0;">Password Reset Request</h2>
<p>We received a request to reset your password. Click the button below to choose a new one.</p> <p>We received a request to reset your password. Click the button below to choose a new one.</p>
<div style="margin: 30px 0;"> <div style="margin: 30px 0; text-align: center;">
<a href="{reset_url}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a> <a href="{reset_url}" style="background: linear-gradient(135deg, #2563eb, #4f46e5); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: 600; display: inline-block;">Reset Password</a>
</div> </div>
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p> <p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
<p style="font-size: 12px; color: #666;">{reset_url}</p> <p style="font-size: 12px; color: #888; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">{reset_url}</p>
<p style="font-size: 14px; color: #666;">This link expires in 1 hour.</p> <p style="font-size: 14px; color: #e74c3c; font-weight: 500;"> This link expires in 1 hour.</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;"> <hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #999;">If you didn't request a password reset, you can safely ignore this email.</p> <p style="font-size: 12px; color: #999; margin-bottom: 0;">If you didn't request a password reset, you can safely ignore this email.</p>
</div> </div>
</body> </body>
</html> </html>

233
docs/MAILGUN_SETUP.md Normal file
View file

@ -0,0 +1,233 @@
# Mailgun Integration Spec
**Date:** 2023-12-23
**Domain:** elmeg.xyz
**Purpose:** Transactional email (signup, login, password reset, notifications)
---
## Overview
Elmeg will use **Mailgun** as the transactional email provider instead of AWS SES. Traffic is low-volume and strictly transactional; no marketing or bulk sending.
---
## Account & Domain Setup
### 1. Mailgun Account
- Create/use shared Mailgun org account owned by Antigravity (not personal)
- Add maintainers with least-privilege roles
### 2. Domain Configuration
- **Sending Domain:** `mail.elmeg.xyz` (subdomain recommended)
- Select appropriate region (US or EU)
### 3. DNS Records (Cloudflare)
Add the following records as provided by Mailgun:
```
# SPF
TXT mail.elmeg.xyz "v=spf1 include:mailgun.org ~all"
# DKIM (2 records typically)
TXT smtp._domainkey.mail.elmeg.xyz "k=rsa; p=..."
TXT mailo._domainkey.mail.elmeg.xyz "k=rsa; p=..."
# DMARC
TXT _dmarc.mail.elmeg.xyz "v=DMARC1; p=none; rua=mailto:dmarc@elmeg.xyz"
# MX (for receiving bounces)
MX mail.elmeg.xyz mxa.mailgun.org 10
MX mail.elmeg.xyz mxb.mailgun.org 20
# Tracking (optional)
CNAME email.mail.elmeg.xyz mailgun.org
```
Wait for Mailgun to show domain as **"Verified"** before switching production.
---
## Sending Model & Limits
| Aspect | Configuration |
|--------|---------------|
| Plan | Free tier (~100 emails/day), scales to pay-per-use |
| Budget | Well under $10/month for current volume |
| Sending Identity | `no-reply@mail.elmeg.xyz` for notifications |
| Reply Address | `support@elmeg.xyz` for human replies |
| Email Types | Account creation, login/OTP, password reset, security alerts |
| **Not Allowed** | Newsletters, promos, bulk imports |
---
## Application Integration
### Environment Variables
```env
# Mailgun API Credentials
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
MAILGUN_DOMAIN=mail.elmeg.xyz
MAILGUN_API_BASE=https://api.mailgun.net/v3 # or eu.api.mailgun.net for EU
# Legacy (keep for transition)
EMAIL_FROM=no-reply@mail.elmeg.xyz
# Optional SMTP (if not using API)
MAILGUN_SMTP_HOST=smtp.mailgun.org
MAILGUN_SMTP_PORT=587
MAILGUN_SMTP_LOGIN=postmaster@mail.elmeg.xyz
MAILGUN_SMTP_PASSWORD=xxxxxxxx
```
### Recommended: Use HTTP API
Prefer Mailgun's HTTP API over SMTP for:
- Better error reporting
- Delivery metrics
- Simpler debugging
### Code Changes Required
Update `backend/services/email.py` (or equivalent) to:
```python
import httpx
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN")
MAILGUN_API_BASE = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3")
async def send_email(to: str, subject: str, text: str, html: str = None):
"""Send email via Mailgun API"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{MAILGUN_API_BASE}/{MAILGUN_DOMAIN}/messages",
auth=("api", MAILGUN_API_KEY),
data={
"from": f"Elmeg <no-reply@{MAILGUN_DOMAIN}>",
"to": to,
"subject": subject,
"text": text,
"html": html,
}
)
response.raise_for_status()
return response.json()
```
---
## Email Templates
### Registration/Verification
```
Subject: Verify your Elmeg account
From: Elmeg <no-reply@mail.elmeg.xyz>
Hi {username},
Welcome to Elmeg! Please verify your email address:
{verification_link}
This link expires in 24 hours.
If you didn't create this account, please ignore this email.
— The Elmeg Team
```
### Password Reset
```
Subject: Reset your Elmeg password
From: Elmeg <no-reply@mail.elmeg.xyz>
Hi {username},
You requested a password reset. Click below to set a new password:
{reset_link}
This link expires in 1 hour.
If you didn't request this, you can safely ignore this email.
— The Elmeg Team
```
---
## Deliverability & Monitoring
| Setting | Value |
|---------|-------|
| Open/Click Tracking | Off (for transactional, reduces complexity) |
| Bounce Handling | Monitor in Mailgun dashboard |
| Target Metrics | <1% bounce rate, <0.1% complaints |
### Monitoring Checklist
- [ ] Check Mailgun dashboard weekly for delivery rates
- [ ] Review any bounces/complaints immediately
- [ ] Ramp up gradually if sending volume increases
---
## Migration Steps
### Phase 1: Setup (Today)
1. [ ] Create/access Mailgun account
2. [ ] Add `mail.elmeg.xyz` domain
3. [ ] Configure DNS in Cloudflare
4. [ ] Wait for domain verification
### Phase 2: Integration
5. [ ] Add `httpx` to requirements.txt
2. [ ] Update email service to use Mailgun API
3. [ ] Add environment variables to production `.env`
4. [ ] Test with a single email before full migration
### Phase 3: Switchover
9. [ ] Remove AWS SES credentials from production
2. [ ] Update documentation
3. [ ] Monitor deliverability for first week
---
## Security & Access
| Role | Access |
|------|--------|
| Org Owner | Full Mailgun access (Antigravity) |
| Maintainers | Send access, dashboard view |
| DNS | Cloudflare managed by Antigravity |
| Credentials | Environment variables only, never in Git |
---
## Future Considerations
- **Marketing Email:** If needed, use separate provider (Sendgrid, Postmark) to keep Mailgun clean
- **High Volume:** If >100/day, upgrade to paid tier
- **Webhooks:** Mailgun can send delivery/bounce webhooks for advanced tracking
---
## Cost Estimate
| Tier | Volume | Cost |
|------|--------|------|
| Free | ~100/day | $0 |
| Flex | Pay per 1,000 | ~$0.80/1,000 |
| Estimated Monthly | ~500 emails | <$1 |