refactor: Switch from SendGrid to AWS SES for email

- Replace httpx/SendGrid with boto3/SES
- Add boto3 to requirements.txt
- Create AWS_SES_SETUP.md documentation
- Remove SendGrid setup doc
This commit is contained in:
fullsizemalt 2025-12-21 13:42:07 -08:00
parent f1d8a14f75
commit 9af0bc4b96
3 changed files with 183 additions and 22 deletions

View file

@ -1,43 +1,56 @@
""" """
Email Service - SendGrid integration for verification and password reset emails. Email Service - AWS SES integration for verification and password reset emails.
""" """
import os import os
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional import boto3
import httpx from botocore.exceptions import ClientError
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "") AWS_REGION = os.getenv("AWS_SES_REGION", "us-east-1")
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz") EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run") FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run")
async def send_email(to: str, subject: str, html_content: str) -> bool: # AWS credentials from environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
"""Send email via SendGrid API""" # or from IAM role if running on AWS infrastructure
if not SENDGRID_API_KEY:
def get_ses_client():
"""Get boto3 SES client"""
return boto3.client('ses', region_name=AWS_REGION)
def send_email(to: str, subject: str, html_content: str) -> bool:
"""Send email via AWS SES"""
# Dev mode - just log
if not os.getenv("AWS_ACCESS_KEY_ID"):
print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}") print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...") print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...")
return True return True
async with httpx.AsyncClient() as client: try:
response = await client.post( client = get_ses_client()
"https://api.sendgrid.com/v3/mail/send", response = client.send_email(
headers={ Source=EMAIL_FROM,
"Authorization": f"Bearer {SENDGRID_API_KEY}", Destination={'ToAddresses': [to]},
"Content-Type": "application/json" Message={
}, 'Subject': {'Data': subject, 'Charset': 'UTF-8'},
json={ 'Body': {
"personalizations": [{"to": [{"email": to}]}], 'Html': {'Data': html_content, 'Charset': 'UTF-8'}
"from": {"email": EMAIL_FROM, "name": "Elmeg"}, }
"subject": subject,
"content": [{"type": "text/html", "value": html_content}]
} }
) )
return response.status_code in (200, 202) print(f"Email sent to {to}, MessageId: {response['MessageId']}")
return True
except ClientError as e:
print(f"Failed to send email: {e.response['Error']['Message']}")
return False
def generate_token() -> str: def generate_token() -> str:
"""Generate a secure random token""" """Generate a secure random token"""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
async def send_verification_email(email: str, token: str) -> bool: async def send_verification_email(email: str, token: str) -> bool:
"""Send email verification link""" """Send email verification link"""
verify_url = f"{FRONTEND_URL}/verify-email?token={token}" verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
@ -61,7 +74,8 @@ async def send_verification_email(email: str, token: str) -> bool:
</div> </div>
""" """
return await send_email(email, "Verify your Elmeg account", html) return send_email(email, "Verify your Elmeg account", html)
async def send_password_reset_email(email: str, token: str) -> bool: async def send_password_reset_email(email: str, token: str) -> bool:
"""Send password reset link""" """Send password reset link"""
@ -86,13 +100,16 @@ async def send_password_reset_email(email: str, token: str) -> bool:
</div> </div>
""" """
return await send_email(email, "Reset your Elmeg password", html) return send_email(email, "Reset your Elmeg password", html)
# Token expiration helpers # Token expiration helpers
def get_verification_expiry() -> datetime: def get_verification_expiry() -> datetime:
"""48 hour expiry for email verification""" """48 hour expiry for email verification"""
return datetime.utcnow() + timedelta(hours=48) return datetime.utcnow() + timedelta(hours=48)
def get_reset_expiry() -> datetime: def get_reset_expiry() -> datetime:
"""1 hour expiry for password reset""" """1 hour expiry for password reset"""
return datetime.utcnow() + timedelta(hours=1) return datetime.utcnow() + timedelta(hours=1)

View file

@ -11,3 +11,4 @@ argon2-cffi
psycopg2-binary psycopg2-binary
requests requests
beautifulsoup4 beautifulsoup4
boto3

143
docs/AWS_SES_SETUP.md Normal file
View file

@ -0,0 +1,143 @@
# AWS SES Email Setup
## Overview
The Elmeg platform uses AWS SES (Simple Email Service) for sending transactional emails (verification, password reset). Cost: ~$0.10 per 1,000 emails.
---
## Prerequisites
- AWS Account
- Domain: `elmeg.xyz` (for verified sender)
- Production server: `tangible-aacorn` / `nexus-vector`
---
## Setup Steps
### 1. Verify Email Domain in SES
1. Go to AWS Console → SES → Verified Identities
2. Click "Create identity" → Choose "Domain"
3. Enter: `elmeg.xyz`
4. Enable "Use a custom MAIL FROM domain" (optional)
5. Add the provided DNS records to your domain
**Required DNS Records** (example):
```
Type: TXT
Name: _amazonses.elmeg.xyz
Value: [provided by AWS]
Type: CNAME
Name: [dkim1]._domainkey.elmeg.xyz
Value: [provided by AWS]
```
### 2. Move Out of SES Sandbox
By default, SES is in sandbox mode (can only send to verified emails).
1. Go to SES → Account dashboard
2. Click "Request production access"
3. Fill out:
- Mail type: Transactional
- Website URL: <https://elmeg.runfoo.run>
- Use case: User registration verification, password reset
4. Wait for approval (usually 24hrs)
### 3. Create IAM User for SES
1. Go to IAM → Users → Create user
2. Name: `elmeg-ses-sender`
3. Attach policy: `AmazonSESFullAccess` (or create custom with `ses:SendEmail` only)
4. Create access key for "Application running outside AWS"
5. Save:
- Access Key ID
- Secret Access Key
### 4. Configure Production Server
SSH into server and update environment:
```bash
ssh root@tangible-aacorn
cd /srv/containers/elmeg-demo
```
Edit `docker-compose.yml`, add to `backend` service:
```yaml
backend:
environment:
- DATABASE_URL=postgresql://elmeg:elmeg@db:5432/elmeg
- AWS_ACCESS_KEY_ID=AKIA...
- AWS_SECRET_ACCESS_KEY=...
- AWS_SES_REGION=us-east-1
- EMAIL_FROM=noreply@elmeg.xyz
- FRONTEND_URL=https://elmeg.runfoo.run
```
Or create `.env` file:
```env
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_SES_REGION=us-east-1
EMAIL_FROM=noreply@elmeg.xyz
FRONTEND_URL=https://elmeg.runfoo.run
```
### 5. Add boto3 to Requirements
```bash
# Add to backend/requirements.txt
boto3>=1.28.0
```
### 6. Run Migration & Restart
```bash
docker compose build backend
docker compose exec backend python migrations/add_email_verification.py
docker compose restart backend
```
### 7. Test
1. Register at <https://elmeg.runfoo.run/register>
2. Check for verification email
3. Test forgot password at <https://elmeg.runfoo.run/forgot-password>
---
## Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `AWS_ACCESS_KEY_ID` | IAM user access key | `AKIA...` |
| `AWS_SECRET_ACCESS_KEY` | IAM user secret | `wJalr...` |
| `AWS_SES_REGION` | SES region | `us-east-1` |
| `EMAIL_FROM` | Verified sender address | `noreply@elmeg.xyz` |
| `FRONTEND_URL` | Base URL for links | `https://elmeg.runfoo.run` |
---
## Cost
- $0.10 per 1,000 emails
- No monthly minimum
- First 62,000 emails/month free if sending from EC2
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Email address is not verified" | Domain not verified in SES, or still in sandbox |
| "Access Denied" | Check IAM permissions for ses:SendEmail |
| Emails not sending | Check `docker compose logs backend` |
| Emails in spam | Complete DKIM setup, verify domain |