diff --git a/backend/services/email_service.py b/backend/services/email_service.py
index 738fb30..b933391 100644
--- a/backend/services/email_service.py
+++ b/backend/services/email_service.py
@@ -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"""
-
-
-
Welcome to Elmeg!
-
Thanks for signing up. Please verify your email address to get started.
-
-
Verify Email Address
+
+
+
Welcome to Elmeg! 🎵
+
Thanks for joining the community. Please verify your email address to get started.
+
Or copy this link to your browser:
-
{verify_url}
+
{verify_url}
-
If you didn't create an account, you can safely ignore this email.
+
If you didn't create an account, you can safely ignore this email.
"""
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"""
-
-
-
Password Reset Request
+
+
+
Password Reset Request
We received a request to reset your password. Click the button below to choose a new one.
-
-
Reset Password
+
Or copy this link to your browser:
-
{reset_url}
-
This link expires in 1 hour.
+
{reset_url}
+
⏰ This link expires in 1 hour.
-
If you didn't request a password reset, you can safely ignore this email.
+
If you didn't request a password reset, you can safely ignore this email.
"""
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)
diff --git a/docs/MAILGUN_SETUP.md b/docs/MAILGUN_SETUP.md
new file mode 100644
index 0000000..66f71c6
--- /dev/null
+++ b/docs/MAILGUN_SETUP.md
@@ -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
",
+ "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
+
+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
+
+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 |