feat: Mailgun email integration with SES fallback
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
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:
parent
1f7f83a31a
commit
68453d6865
2 changed files with 354 additions and 64 deletions
|
|
@ -1,69 +1,126 @@
|
|||
"""
|
||||
Email Service - Mailgun (primary) with AWS SES fallback
|
||||
"""
|
||||
import os
|
||||
import boto3
|
||||
import httpx
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from botocore.exceptions import ClientError
|
||||
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:
|
||||
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_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
# Common settings
|
||||
self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
||||
self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
|
||||
|
||||
# Initialize SES client if credentials exist
|
||||
if self.aws_access_key_id and self.aws_secret_access_key:
|
||||
self.client = boto3.client(
|
||||
|
||||
# Determine which provider to use
|
||||
if self.mailgun_api_key and self.mailgun_domain:
|
||||
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",
|
||||
region_name=self.region_name,
|
||||
region_name=self.aws_region,
|
||||
aws_access_key_id=self.aws_access_key_id,
|
||||
aws_secret_access_key=self.aws_secret_access_key,
|
||||
)
|
||||
print("Email service: Using AWS SES")
|
||||
else:
|
||||
self.client = None
|
||||
print("WARNING: AWS credentials not found. Email service running in dummy mode.")
|
||||
self.provider = "dummy"
|
||||
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):
|
||||
"""Send an email using AWS SES"""
|
||||
if not self.client:
|
||||
print(f"DUMMY EMAIL to {to_email}: {subject}")
|
||||
print(text_content)
|
||||
return True
|
||||
"""Send an email using configured provider"""
|
||||
if self.provider == "mailgun":
|
||||
return self._send_mailgun(to_email, subject, html_content, text_content)
|
||||
elif self.provider == "ses":
|
||||
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:
|
||||
response = self.client.send_email(
|
||||
Source=self.email_from,
|
||||
Destination={
|
||||
"ToAddresses": [to_email],
|
||||
},
|
||||
Message={
|
||||
"Subject": {
|
||||
"Data": subject,
|
||||
"Charset": "UTF-8",
|
||||
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,
|
||||
Destination={"ToAddresses": [to_email]},
|
||||
Message={
|
||||
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
||||
"Body": {
|
||||
"Html": {
|
||||
"Data": html_content,
|
||||
"Charset": "UTF-8",
|
||||
},
|
||||
"Text": {
|
||||
"Data": text_content,
|
||||
"Charset": "UTF-8",
|
||||
},
|
||||
"Html": {"Data": html_content, "Charset": "UTF-8"},
|
||||
"Text": {"Data": text_content, "Charset": "UTF-8"},
|
||||
},
|
||||
},
|
||||
)
|
||||
print(f"Email sent via SES to {to_email}")
|
||||
return response
|
||||
except ClientError as e:
|
||||
print(f"Error sending email: {e.response['Error']['Message']}")
|
||||
print(f"SES error: {e.response['Error']['Message']}")
|
||||
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
|
||||
email_service = EmailService()
|
||||
|
||||
# --- Helper Functions (used by auth router) ---
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def generate_token() -> str:
|
||||
"""Generate a secure random token"""
|
||||
|
|
@ -85,29 +142,29 @@ def send_verification_email(to_email: str, token: str):
|
|||
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Welcome to Elmeg!</h2>
|
||||
<p>Thanks for signing up. Please verify your email address to get started.</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<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>
|
||||
<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; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #2563eb; margin-top: 0;">Welcome to Elmeg! 🎵</h2>
|
||||
<p>Thanks for joining the community. Please verify your email address to get started.</p>
|
||||
<div style="margin: 30px 0; text-align: center;">
|
||||
<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>
|
||||
<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;">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text_content = f"""
|
||||
Welcome to Elmeg!
|
||||
|
||||
Please verify your email address by visiting this link:
|
||||
{verify_url}
|
||||
|
||||
If you didn't create an account, safely ignore this email.
|
||||
Welcome to Elmeg!
|
||||
|
||||
Please verify your email address by visiting this link:
|
||||
{verify_url}
|
||||
|
||||
If you didn't create an account, safely ignore this email.
|
||||
"""
|
||||
|
||||
return email_service.send_email(to_email, subject, html_content, text_content)
|
||||
|
|
@ -120,32 +177,32 @@ def send_password_reset_email(to_email: str, token: str):
|
|||
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb;">Password Reset Request</h2>
|
||||
<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; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<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>
|
||||
<div style="margin: 30px 0;">
|
||||
<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>
|
||||
<div style="margin: 30px 0; text-align: center;">
|
||||
<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>
|
||||
<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: 14px; color: #666;">This link expires in 1 hour.</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: #e74c3c; font-weight: 500;">⏰ This link expires in 1 hour.</p>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
text_content = f"""
|
||||
Reset your Elmeg password
|
||||
|
||||
Click the link below to choose a new password:
|
||||
{reset_url}
|
||||
|
||||
This link expires in 1 hour.
|
||||
|
||||
If you didn't request a password reset, safely ignore this email.
|
||||
Reset your Elmeg password
|
||||
|
||||
Click the link below to choose a new password:
|
||||
{reset_url}
|
||||
|
||||
This link expires in 1 hour.
|
||||
|
||||
If you didn't request a password reset, safely ignore this email.
|
||||
"""
|
||||
|
||||
return email_service.send_email(to_email, subject, html_content, text_content)
|
||||
|
|
|
|||
233
docs/MAILGUN_SETUP.md
Normal file
233
docs/MAILGUN_SETUP.md
Normal 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 |
|
||||
Loading…
Add table
Reference in a new issue