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:
parent
f1d8a14f75
commit
9af0bc4b96
3 changed files with 183 additions and 22 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ argon2-cffi
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
requests
|
requests
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
boto3
|
||||||
|
|
|
||||||
143
docs/AWS_SES_SETUP.md
Normal file
143
docs/AWS_SES_SETUP.md
Normal 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 |
|
||||||
Loading…
Add table
Reference in a new issue